Browse Source

feat: add mechanism for determining if a user should use the primary or secondary items database (#700)

* feat(domain-core): introduce new role for users transitioning to new mechanisms

* feat: add mechanism for determining if a user should use the primary or secondary items database

* fix: add transition mode enabled switch in docker entrypoint

* fix(syncing-server): mapping roles from middleware

* fix: mongodb item repository binding

* fix: item backups service binding

* fix: passing transition mode enabled variable to docker setup
Karol Sójko 1 year ago
parent
commit
302b624504
58 changed files with 1040 additions and 413 deletions
  1. 1 0
      docker-compose.ci.yml
  2. 3 0
      docker/docker-entrypoint.sh
  3. 13 0
      packages/auth/migrations/mysql/1692348191367-add-transition-role.ts
  4. 13 0
      packages/auth/migrations/sqlite/1692348280258-add-transition-role.ts
  5. 3 0
      packages/auth/src/Bootstrap/Container.ts
  6. 1 0
      packages/auth/src/Bootstrap/Types.ts
  7. 56 1
      packages/auth/src/Domain/UseCase/Register.spec.ts
  8. 12 3
      packages/auth/src/Domain/UseCase/Register.ts
  9. 11 0
      packages/domain-core/src/Domain/Common/RoleName.spec.ts
  10. 12 3
      packages/domain-core/src/Domain/Common/RoleName.ts
  11. 23 36
      packages/domain-core/src/Domain/Common/RoleNameCollection.spec.ts
  12. 11 2
      packages/domain-core/src/Domain/Common/RoleNameCollection.ts
  13. 2 0
      packages/home-server/.env.sample
  14. 115 101
      packages/syncing-server/src/Bootstrap/Container.ts
  15. 4 2
      packages/syncing-server/src/Bootstrap/Types.ts
  16. 33 6
      packages/syncing-server/src/Domain/Extension/ExtensionsHttpService.spec.ts
  17. 9 3
      packages/syncing-server/src/Domain/Extension/ExtensionsHttpService.ts
  18. 19 6
      packages/syncing-server/src/Domain/Handler/AccountDeletionRequestedEventHandler.spec.ts
  19. 9 2
      packages/syncing-server/src/Domain/Handler/AccountDeletionRequestedEventHandler.ts
  20. 27 6
      packages/syncing-server/src/Domain/Handler/DuplicateItemSyncedEventHandler.spec.ts
  21. 15 3
      packages/syncing-server/src/Domain/Handler/DuplicateItemSyncedEventHandler.ts
  22. 25 4
      packages/syncing-server/src/Domain/Handler/EmailBackupRequestedEventHandler.spec.ts
  23. 16 3
      packages/syncing-server/src/Domain/Handler/EmailBackupRequestedEventHandler.ts
  24. 19 5
      packages/syncing-server/src/Domain/Handler/ItemRevisionCreationRequestedEventHandler.spec.ts
  25. 14 2
      packages/syncing-server/src/Domain/Handler/ItemRevisionCreationRequestedEventHandler.ts
  26. 15 0
      packages/syncing-server/src/Domain/Item/ItemContentSizeDescriptor.spec.ts
  27. 24 0
      packages/syncing-server/src/Domain/Item/ItemContentSizeDescriptor.ts
  28. 6 0
      packages/syncing-server/src/Domain/Item/ItemContentSizeDescriptorProps.ts
  29. 2 3
      packages/syncing-server/src/Domain/Item/ItemRepositoryInterface.ts
  30. 7 0
      packages/syncing-server/src/Domain/Item/ItemRepositoryResolverInterface.ts
  31. 88 146
      packages/syncing-server/src/Domain/Item/ItemTransferCalculator.spec.ts
  32. 20 17
      packages/syncing-server/src/Domain/Item/ItemTransferCalculator.ts
  33. 9 3
      packages/syncing-server/src/Domain/Item/ItemTransferCalculatorInterface.ts
  34. 33 2
      packages/syncing-server/src/Domain/UseCase/Syncing/CheckIntegrity/CheckIntegrity.spec.ts
  35. 11 4
      packages/syncing-server/src/Domain/UseCase/Syncing/CheckIntegrity/CheckIntegrity.ts
  36. 1 0
      packages/syncing-server/src/Domain/UseCase/Syncing/CheckIntegrity/CheckIntegrityDTO.ts
  37. 19 1
      packages/syncing-server/src/Domain/UseCase/Syncing/GetItem/GetItem.spec.ts
  38. 11 4
      packages/syncing-server/src/Domain/UseCase/Syncing/GetItem/GetItem.ts
  39. 1 0
      packages/syncing-server/src/Domain/UseCase/Syncing/GetItem/GetItemDTO.ts
  40. 34 2
      packages/syncing-server/src/Domain/UseCase/Syncing/GetItems/GetItems.spec.ts
  41. 15 6
      packages/syncing-server/src/Domain/UseCase/Syncing/GetItems/GetItems.ts
  42. 1 0
      packages/syncing-server/src/Domain/UseCase/Syncing/GetItems/GetItemsDTO.ts
  43. 36 2
      packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItems.spec.ts
  44. 13 4
      packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItems.ts
  45. 1 0
      packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItemsDTO.ts
  46. 38 2
      packages/syncing-server/src/Domain/UseCase/Syncing/SaveNewItem/SaveNewItem.spec.ts
  47. 12 3
      packages/syncing-server/src/Domain/UseCase/Syncing/SaveNewItem/SaveNewItem.ts
  48. 1 0
      packages/syncing-server/src/Domain/UseCase/Syncing/SaveNewItem/SaveNewItemDTO.ts
  49. 36 2
      packages/syncing-server/src/Domain/UseCase/Syncing/SyncItems/SyncItems.spec.ts
  50. 19 6
      packages/syncing-server/src/Domain/UseCase/Syncing/SyncItems/SyncItems.ts
  51. 1 0
      packages/syncing-server/src/Domain/UseCase/Syncing/SyncItems/SyncItemsDTO.ts
  52. 43 1
      packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem.spec.ts
  53. 12 3
      packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem.ts
  54. 1 0
      packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItemDTO.ts
  55. 5 1
      packages/syncing-server/src/Infra/InversifyExpressUtils/Base/BaseItemsController.ts
  56. 17 9
      packages/syncing-server/src/Infra/TypeORM/MongoDBItemRepository.ts
  57. 17 4
      packages/syncing-server/src/Infra/TypeORM/TypeORMItemRepository.ts
  58. 25 0
      packages/syncing-server/src/Infra/TypeORM/TypeORMItemRepositoryResolver.ts

+ 1 - 0
docker-compose.ci.yml

@@ -24,6 +24,7 @@ services:
       DB_TYPE: "${DB_TYPE}"
       CACHE_TYPE: "${CACHE_TYPE}"
       SECONDARY_DB_ENABLED: "${SECONDARY_DB_ENABLED}"
+      TRANSITION_MODE_ENABLED: "${TRANSITION_MODE_ENABLED}"
     container_name: server-ci
     ports:
       - 3123:3000

+ 3 - 0
docker/docker-entrypoint.sh

@@ -66,6 +66,9 @@ fi
 if [ -z "$SECONDARY_DB_ENABLED" ]; then
   export SECONDARY_DB_ENABLED=false
 fi
+if [ -z "$TRANSITION_MODE_ENABLED" ]; then
+  export TRANSITION_MODE_ENABLED=false
+fi
 export DB_MIGRATIONS_PATH="dist/migrations/*.js"
 
 #########

+ 13 - 0
packages/auth/migrations/mysql/1692348191367-add-transition-role.ts

@@ -0,0 +1,13 @@
+import { MigrationInterface, QueryRunner } from 'typeorm'
+
+export class AddTransitionRole1692348191367 implements MigrationInterface {
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(
+      'INSERT INTO `roles` (uuid, name, version) VALUES ("e7381dc5-3d67-49e9-b7bd-f2407b2f726e", "TRANSITION_USER", 1)',
+    )
+  }
+
+  public async down(): Promise<void> {
+    return
+  }
+}

+ 13 - 0
packages/auth/migrations/sqlite/1692348280258-add-transition-role.ts

@@ -0,0 +1,13 @@
+import { MigrationInterface, QueryRunner } from 'typeorm'
+
+export class AddTransitionRole1692348280258 implements MigrationInterface {
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(
+      'INSERT INTO `roles` (uuid, name, version) VALUES ("e7381dc5-3d67-49e9-b7bd-f2407b2f726e", "TRANSITION_USER", 1)',
+    )
+  }
+
+  public async down(): Promise<void> {
+    return
+  }
+}

+ 3 - 0
packages/auth/src/Bootstrap/Container.ts

@@ -560,6 +560,9 @@ export class ContainerConfigLoader {
     container
       .bind(TYPES.Auth_READONLY_USERS)
       .toConstantValue(env.get('READONLY_USERS', true) ? env.get('READONLY_USERS', true).split(',') : [])
+    container
+      .bind(TYPES.Auth_TRANSITION_MODE_ENABLED)
+      .toConstantValue(env.get('TRANSITION_MODE_ENABLED', true) === 'true')
 
     if (isConfiguredForInMemoryCache) {
       container

+ 1 - 0
packages/auth/src/Bootstrap/Types.ts

@@ -101,6 +101,7 @@ const TYPES = {
   Auth_U2F_EXPECTED_ORIGIN: Symbol.for('Auth_U2F_EXPECTED_ORIGIN'),
   Auth_U2F_REQUIRE_USER_VERIFICATION: Symbol.for('Auth_U2F_REQUIRE_USER_VERIFICATION'),
   Auth_READONLY_USERS: Symbol.for('Auth_READONLY_USERS'),
+  Auth_TRANSITION_MODE_ENABLED: Symbol.for('Auth_TRANSITION_MODE_ENABLED'),
   // use cases
   Auth_AuthenticateUser: Symbol.for('Auth_AuthenticateUser'),
   Auth_AuthenticateRequest: Symbol.for('Auth_AuthenticateRequest'),

+ 56 - 1
packages/auth/src/Domain/UseCase/Register.spec.ts

@@ -11,6 +11,7 @@ import { Register } from './Register'
 import { SettingServiceInterface } from '../Setting/SettingServiceInterface'
 import { AuthResponseFactory20200115 } from '../Auth/AuthResponseFactory20200115'
 import { Session } from '../Session/Session'
+import { RoleName } from '@standardnotes/domain-core'
 
 describe('Register', () => {
   let userRepository: UserRepositoryInterface
@@ -20,9 +21,19 @@ describe('Register', () => {
   let user: User
   let crypter: CrypterInterface
   let timer: TimerInterface
+  let transitionModeEnabled = false
 
   const createUseCase = () =>
-    new Register(userRepository, roleRepository, authResponseFactory, crypter, false, settingService, timer)
+    new Register(
+      userRepository,
+      roleRepository,
+      authResponseFactory,
+      crypter,
+      false,
+      settingService,
+      timer,
+      transitionModeEnabled,
+    )
 
   beforeEach(() => {
     userRepository = {} as jest.Mocked<UserRepositoryInterface>
@@ -75,6 +86,7 @@ describe('Register', () => {
       updatedWithUserAgent: 'Mozilla',
       uuid: expect.any(String),
       version: '004',
+      roles: Promise.resolve([]),
       createdAt: new Date(1),
       updatedAt: new Date(1),
     })
@@ -118,6 +130,48 @@ describe('Register', () => {
     })
   })
 
+  it('should register a new user with default role and transition role', async () => {
+    transitionModeEnabled = true
+
+    const role = new Role()
+    role.name = RoleName.NAMES.CoreUser
+
+    const transitionRole = new Role()
+    transitionRole.name = RoleName.NAMES.TransitionUser
+
+    roleRepository.findOneByName = jest.fn().mockReturnValueOnce(role).mockReturnValueOnce(transitionRole)
+
+    expect(
+      await createUseCase().execute({
+        email: 'test@test.te',
+        password: 'asdzxc',
+        updatedWithUserAgent: 'Mozilla',
+        apiVersion: '20200115',
+        ephemeralSession: false,
+        version: '004',
+        pwCost: 11,
+        pwSalt: 'qweqwe',
+        pwNonce: undefined,
+      }),
+    ).toEqual({ success: true, authResponse: { foo: 'bar' } })
+
+    expect(userRepository.save).toHaveBeenCalledWith({
+      email: 'test@test.te',
+      encryptedPassword: expect.any(String),
+      encryptedServerKey: 'test',
+      serverEncryptionVersion: 1,
+      pwCost: 11,
+      pwNonce: undefined,
+      pwSalt: 'qweqwe',
+      updatedWithUserAgent: 'Mozilla',
+      uuid: expect.any(String),
+      version: '004',
+      createdAt: new Date(1),
+      updatedAt: new Date(1),
+      roles: Promise.resolve([role, transitionRole]),
+    })
+  })
+
   it('should fail to register if username is invalid', async () => {
     expect(
       await createUseCase().execute({
@@ -195,6 +249,7 @@ describe('Register', () => {
         true,
         settingService,
         timer,
+        transitionModeEnabled,
       ).execute({
         email: 'test@test.te',
         password: 'asdzxc',

+ 12 - 3
packages/auth/src/Domain/UseCase/Register.ts

@@ -1,8 +1,9 @@
 import * as bcrypt from 'bcryptjs'
 import { RoleName, Username } from '@standardnotes/domain-core'
-
 import { v4 as uuidv4 } from 'uuid'
 import { inject, injectable } from 'inversify'
+import { TimerInterface } from '@standardnotes/time'
+
 import TYPES from '../../Bootstrap/Types'
 import { User } from '../User/User'
 import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
@@ -11,7 +12,6 @@ import { RegisterResponse } from './RegisterResponse'
 import { UseCaseInterface } from './UseCaseInterface'
 import { RoleRepositoryInterface } from '../Role/RoleRepositoryInterface'
 import { CrypterInterface } from '../Encryption/CrypterInterface'
-import { TimerInterface } from '@standardnotes/time'
 import { SettingServiceInterface } from '../Setting/SettingServiceInterface'
 import { AuthResponseFactory20200115 } from '../Auth/AuthResponseFactory20200115'
 import { AuthResponse20200115 } from '../Auth/AuthResponse20200115'
@@ -27,6 +27,7 @@ export class Register implements UseCaseInterface {
     @inject(TYPES.Auth_DISABLE_USER_REGISTRATION) private disableUserRegistration: boolean,
     @inject(TYPES.Auth_SettingService) private settingService: SettingServiceInterface,
     @inject(TYPES.Auth_Timer) private timer: TimerInterface,
+    @inject(TYPES.Auth_TRANSITION_MODE_ENABLED) private transitionModeEnabled: boolean,
   ) {}
 
   async execute(dto: RegisterDTO): Promise<RegisterResponse> {
@@ -72,10 +73,18 @@ export class Register implements UseCaseInterface {
     user.encryptedServerKey = await this.crypter.generateEncryptedUserServerKey()
     user.serverEncryptionVersion = User.DEFAULT_ENCRYPTION_VERSION
 
+    const roles = []
     const defaultRole = await this.roleRepository.findOneByName(RoleName.NAMES.CoreUser)
     if (defaultRole) {
-      user.roles = Promise.resolve([defaultRole])
+      roles.push(defaultRole)
+    }
+    if (this.transitionModeEnabled) {
+      const transitionRole = await this.roleRepository.findOneByName(RoleName.NAMES.TransitionUser)
+      if (transitionRole) {
+        roles.push(transitionRole)
+      }
     }
+    user.roles = Promise.resolve(roles)
 
     Object.assign(user, registrationFields)
 

+ 11 - 0
packages/domain-core/src/Domain/Common/RoleName.spec.ts

@@ -21,25 +21,36 @@ describe('RoleName', () => {
     const plusUserRole = RoleName.create(RoleName.NAMES.PlusUser).getValue()
     const coreUser = RoleName.create(RoleName.NAMES.CoreUser).getValue()
     const internalTeamUser = RoleName.create(RoleName.NAMES.InternalTeamUser).getValue()
+    const transitionUser = RoleName.create(RoleName.NAMES.TransitionUser).getValue()
 
     expect(internalTeamUser.hasMoreOrEqualPowerTo(proUserRole)).toBeTruthy()
     expect(internalTeamUser.hasMoreOrEqualPowerTo(proUserRole)).toBeTruthy()
     expect(internalTeamUser.hasMoreOrEqualPowerTo(plusUserRole)).toBeTruthy()
     expect(internalTeamUser.hasMoreOrEqualPowerTo(coreUser)).toBeTruthy()
+    expect(internalTeamUser.hasMoreOrEqualPowerTo(transitionUser)).toBeTruthy()
 
     expect(proUserRole.hasMoreOrEqualPowerTo(internalTeamUser)).toBeFalsy()
     expect(proUserRole.hasMoreOrEqualPowerTo(proUserRole)).toBeTruthy()
     expect(proUserRole.hasMoreOrEqualPowerTo(plusUserRole)).toBeTruthy()
     expect(proUserRole.hasMoreOrEqualPowerTo(coreUser)).toBeTruthy()
+    expect(proUserRole.hasMoreOrEqualPowerTo(transitionUser)).toBeTruthy()
 
     expect(plusUserRole.hasMoreOrEqualPowerTo(internalTeamUser)).toBeFalsy()
     expect(plusUserRole.hasMoreOrEqualPowerTo(proUserRole)).toBeFalsy()
     expect(plusUserRole.hasMoreOrEqualPowerTo(plusUserRole)).toBeTruthy()
     expect(plusUserRole.hasMoreOrEqualPowerTo(coreUser)).toBeTruthy()
+    expect(plusUserRole.hasMoreOrEqualPowerTo(transitionUser)).toBeTruthy()
 
     expect(coreUser.hasMoreOrEqualPowerTo(internalTeamUser)).toBeFalsy()
     expect(coreUser.hasMoreOrEqualPowerTo(proUserRole)).toBeFalsy()
     expect(coreUser.hasMoreOrEqualPowerTo(plusUserRole)).toBeFalsy()
     expect(coreUser.hasMoreOrEqualPowerTo(coreUser)).toBeTruthy()
+    expect(coreUser.hasMoreOrEqualPowerTo(transitionUser)).toBeTruthy()
+
+    expect(transitionUser.hasMoreOrEqualPowerTo(internalTeamUser)).toBeFalsy()
+    expect(transitionUser.hasMoreOrEqualPowerTo(proUserRole)).toBeFalsy()
+    expect(transitionUser.hasMoreOrEqualPowerTo(plusUserRole)).toBeFalsy()
+    expect(transitionUser.hasMoreOrEqualPowerTo(coreUser)).toBeTruthy()
+    expect(transitionUser.hasMoreOrEqualPowerTo(transitionUser)).toBeTruthy()
   })
 })

+ 12 - 3
packages/domain-core/src/Domain/Common/RoleName.ts

@@ -8,6 +8,7 @@ export class RoleName extends ValueObject<RoleNameProps> {
     PlusUser: 'PLUS_USER',
     ProUser: 'PRO_USER',
     InternalTeamUser: 'INTERNAL_TEAM_USER',
+    TransitionUser: 'TRANSITION_USER',
   }
 
   get value(): string {
@@ -19,11 +20,19 @@ export class RoleName extends ValueObject<RoleNameProps> {
       case RoleName.NAMES.InternalTeamUser:
         return true
       case RoleName.NAMES.ProUser:
-        return [RoleName.NAMES.CoreUser, RoleName.NAMES.PlusUser, RoleName.NAMES.ProUser].includes(roleName.value)
+        return [
+          RoleName.NAMES.CoreUser,
+          RoleName.NAMES.PlusUser,
+          RoleName.NAMES.ProUser,
+          RoleName.NAMES.TransitionUser,
+        ].includes(roleName.value)
       case RoleName.NAMES.PlusUser:
-        return [RoleName.NAMES.CoreUser, RoleName.NAMES.PlusUser].includes(roleName.value)
+        return [RoleName.NAMES.CoreUser, RoleName.NAMES.PlusUser, RoleName.NAMES.TransitionUser].includes(
+          roleName.value,
+        )
       case RoleName.NAMES.CoreUser:
-        return [RoleName.NAMES.CoreUser].includes(roleName.value)
+      case RoleName.NAMES.TransitionUser:
+        return [RoleName.NAMES.CoreUser, RoleName.NAMES.TransitionUser].includes(roleName.value)
       /*istanbul ignore next*/
       default:
         throw new Error(`Invalid role name: ${this.value}`)

+ 23 - 36
packages/domain-core/src/Domain/Common/RoleNameCollection.spec.ts

@@ -3,32 +3,24 @@ import { RoleNameCollection } from './RoleNameCollection'
 
 describe('RoleNameCollection', () => {
   it('should create a value object', () => {
-    const role1 = RoleName.create(RoleName.NAMES.ProUser).getValue()
-
-    const valueOrError = RoleNameCollection.create([role1])
+    const valueOrError = RoleNameCollection.create([RoleName.NAMES.ProUser])
 
     expect(valueOrError.isFailed()).toBeFalsy()
-    expect(valueOrError.getValue().value).toEqual([role1])
+    expect(valueOrError.getValue().value[0].value).toEqual('PRO_USER')
   })
 
   it('should tell if collections are not equal', () => {
-    const roles1 = [
-      RoleName.create(RoleName.NAMES.ProUser).getValue(),
-      RoleName.create(RoleName.NAMES.PlusUser).getValue(),
-    ]
-
-    let roles2 = RoleNameCollection.create([
-      RoleName.create(RoleName.NAMES.ProUser).getValue(),
-      RoleName.create(RoleName.NAMES.CoreUser).getValue(),
-    ]).getValue()
+    const roles1 = [RoleName.NAMES.ProUser, RoleName.NAMES.PlusUser]
+
+    let roles2 = RoleNameCollection.create([RoleName.NAMES.ProUser, RoleName.NAMES.CoreUser]).getValue()
 
     let valueOrError = RoleNameCollection.create(roles1)
     expect(valueOrError.getValue().equals(roles2)).toBeFalsy()
 
     roles2 = RoleNameCollection.create([
-      RoleName.create(RoleName.NAMES.ProUser).getValue(),
-      RoleName.create(RoleName.NAMES.PlusUser).getValue(),
-      RoleName.create(RoleName.NAMES.CoreUser).getValue(),
+      RoleName.NAMES.ProUser,
+      RoleName.NAMES.PlusUser,
+      RoleName.NAMES.CoreUser,
     ]).getValue()
 
     valueOrError = RoleNameCollection.create(roles1)
@@ -36,42 +28,30 @@ describe('RoleNameCollection', () => {
   })
 
   it('should tell if collections are equal', () => {
-    const roles1 = [
-      RoleName.create(RoleName.NAMES.ProUser).getValue(),
-      RoleName.create(RoleName.NAMES.PlusUser).getValue(),
-    ]
-
-    const roles2 = RoleNameCollection.create([
-      RoleName.create(RoleName.NAMES.ProUser).getValue(),
-      RoleName.create(RoleName.NAMES.PlusUser).getValue(),
-    ]).getValue()
+    const roles1 = [RoleName.NAMES.ProUser, RoleName.NAMES.PlusUser]
+
+    const roles2 = RoleNameCollection.create([RoleName.NAMES.ProUser, RoleName.NAMES.PlusUser]).getValue()
 
     const valueOrError = RoleNameCollection.create(roles1)
     expect(valueOrError.getValue().equals(roles2)).toBeTruthy()
   })
 
   it('should tell if collection includes element', () => {
-    const roles1 = [
-      RoleName.create(RoleName.NAMES.ProUser).getValue(),
-      RoleName.create(RoleName.NAMES.PlusUser).getValue(),
-    ]
+    const roles1 = [RoleName.NAMES.ProUser, RoleName.NAMES.PlusUser]
 
     const valueOrError = RoleNameCollection.create(roles1)
     expect(valueOrError.getValue().includes(RoleName.create(RoleName.NAMES.ProUser).getValue())).toBeTruthy()
   })
 
   it('should tell if collection does not includes element', () => {
-    const roles1 = [
-      RoleName.create(RoleName.NAMES.ProUser).getValue(),
-      RoleName.create(RoleName.NAMES.PlusUser).getValue(),
-    ]
+    const roles1 = [RoleName.NAMES.ProUser, RoleName.NAMES.PlusUser]
 
     const valueOrError = RoleNameCollection.create(roles1)
     expect(valueOrError.getValue().includes(RoleName.create(RoleName.NAMES.CoreUser).getValue())).toBeFalsy()
   })
 
   it('should tell if collection has a role with more or equal power to', () => {
-    let roles = [RoleName.create(RoleName.NAMES.CoreUser).getValue()]
+    let roles = [RoleName.NAMES.CoreUser]
     let valueOrError = RoleNameCollection.create(roles)
     let roleNames = valueOrError.getValue()
 
@@ -83,7 +63,7 @@ describe('RoleNameCollection', () => {
       roleNames.hasARoleNameWithMoreOrEqualPowerTo(RoleName.create(RoleName.NAMES.CoreUser).getValue()),
     ).toBeTruthy()
 
-    roles = [RoleName.create(RoleName.NAMES.CoreUser).getValue(), RoleName.create(RoleName.NAMES.PlusUser).getValue()]
+    roles = [RoleName.NAMES.CoreUser, RoleName.NAMES.PlusUser]
     valueOrError = RoleNameCollection.create(roles)
     roleNames = valueOrError.getValue()
 
@@ -95,7 +75,7 @@ describe('RoleNameCollection', () => {
       roleNames.hasARoleNameWithMoreOrEqualPowerTo(RoleName.create(RoleName.NAMES.CoreUser).getValue()),
     ).toBeTruthy()
 
-    roles = [RoleName.create(RoleName.NAMES.ProUser).getValue(), RoleName.create(RoleName.NAMES.PlusUser).getValue()]
+    roles = [RoleName.NAMES.ProUser, RoleName.NAMES.PlusUser]
     valueOrError = RoleNameCollection.create(roles)
     roleNames = valueOrError.getValue()
 
@@ -109,4 +89,11 @@ describe('RoleNameCollection', () => {
       roleNames.hasARoleNameWithMoreOrEqualPowerTo(RoleName.create(RoleName.NAMES.CoreUser).getValue()),
     ).toBeTruthy()
   })
+
+  it('should fail to create a collection if a role name is invalid', () => {
+    const valueOrError = RoleNameCollection.create(['invalid-role-name'])
+
+    expect(valueOrError.isFailed()).toBeTruthy()
+    expect(valueOrError.getError()).toEqual('Invalid role name: invalid-role-name')
+  })
 })

+ 11 - 2
packages/domain-core/src/Domain/Common/RoleNameCollection.ts

@@ -46,7 +46,16 @@ export class RoleNameCollection extends ValueObject<RoleNameCollectionProps> {
     super(props)
   }
 
-  static create(roleName: RoleName[]): Result<RoleNameCollection> {
-    return Result.ok<RoleNameCollection>(new RoleNameCollection({ value: roleName }))
+  static create(roleNameStrings: string[]): Result<RoleNameCollection> {
+    const roleNames: RoleName[] = []
+    for (const roleNameString of roleNameStrings) {
+      const roleNameOrError = RoleName.create(roleNameString)
+      if (roleNameOrError.isFailed()) {
+        return Result.fail<RoleNameCollection>(roleNameOrError.getError())
+      }
+      roleNames.push(roleNameOrError.getValue())
+    }
+
+    return Result.ok<RoleNameCollection>(new RoleNameCollection({ value: roleNames }))
   }
 }

+ 2 - 0
packages/home-server/.env.sample

@@ -16,3 +16,5 @@ MONGO_PORT=27017
 MONGO_USERNAME=standardnotes
 MONGO_PASSWORD=standardnotes
 MONGO_DATABASE=standardnotes
+
+TRANSITION_MODE_ENABLED=false

+ 115 - 101
packages/syncing-server/src/Bootstrap/Container.ts

@@ -39,7 +39,7 @@ import { SyncItems } from '../Domain/UseCase/Syncing/SyncItems/SyncItems'
 import { InversifyExpressAuthMiddleware } from '../Infra/InversifyExpressUtils/Middleware/InversifyExpressAuthMiddleware'
 import { S3Client } from '@aws-sdk/client-s3'
 import { SQSClient, SQSClientConfig } from '@aws-sdk/client-sqs'
-import { ContentDecoder } from '@standardnotes/common'
+import { ContentDecoder, ContentDecoderInterface } from '@standardnotes/common'
 import {
   DomainEventMessageHandlerInterface,
   DomainEventHandlerInterface,
@@ -153,6 +153,9 @@ import { AddNotificationsForUsers } from '../Domain/UseCase/Messaging/AddNotific
 import { MongoDBItem } from '../Infra/TypeORM/MongoDBItem'
 import { MongoDBItemRepository } from '../Infra/TypeORM/MongoDBItemRepository'
 import { MongoDBItemPersistenceMapper } from '../Mapping/Persistence/MongoDB/MongoDBItemPersistenceMapper'
+import { Logger } from 'winston'
+import { ItemRepositoryResolverInterface } from '../Domain/Item/ItemRepositoryResolverInterface'
+import { TypeORMItemRepositoryResolver } from '../Infra/TypeORM/TypeORMItemRepositoryResolver'
 
 export class ContainerConfigLoader {
   private readonly DEFAULT_CONTENT_SIZE_TRANSFER_LIMIT = 10_000_000
@@ -284,6 +287,18 @@ export class ContainerConfigLoader {
       })
     }
 
+    container
+      .bind(TYPES.Sync_EMAIL_ATTACHMENT_MAX_BYTE_SIZE)
+      .toConstantValue(
+        env.get('EMAIL_ATTACHMENT_MAX_BYTE_SIZE', true) ? +env.get('EMAIL_ATTACHMENT_MAX_BYTE_SIZE', true) : 10485760,
+      )
+    container.bind(TYPES.Sync_NEW_RELIC_ENABLED).toConstantValue(env.get('NEW_RELIC_ENABLED', true))
+    container
+      .bind(TYPES.Sync_FILE_UPLOAD_PATH)
+      .toConstantValue(
+        env.get('FILE_UPLOAD_PATH', true) ? env.get('FILE_UPLOAD_PATH', true) : this.DEFAULT_FILE_UPLOAD_PATH,
+      )
+
     // Mapping
     container
       .bind<MapperInterface<Item, TypeORMItem>>(TYPES.Sync_ItemPersistenceMapper)
@@ -374,25 +389,37 @@ export class ContainerConfigLoader {
         .toConstantValue(new MongoDBItemPersistenceMapper())
 
       container
-        .bind<MongoRepository<MongoDBItem>>(TYPES.Sync_MongoItemRepository)
+        .bind<MongoRepository<MongoDBItem>>(TYPES.Sync_ORMMongoItemRepository)
         .toConstantValue(appDataSource.getMongoRepository(MongoDBItem))
+
+      container
+        .bind<ItemRepositoryInterface>(TYPES.Sync_MongoDBItemRepository)
+        .toConstantValue(
+          new MongoDBItemRepository(
+            container.get<MongoRepository<MongoDBItem>>(TYPES.Sync_ORMMongoItemRepository),
+            container.get<MapperInterface<Item, MongoDBItem>>(TYPES.Sync_MongoDBItemPersistenceMapper),
+            container.get<Logger>(TYPES.Sync_Logger),
+          ),
+        )
     }
 
     // Repositories
     container
-      .bind<ItemRepositoryInterface>(TYPES.Sync_ItemRepository)
+      .bind<ItemRepositoryInterface>(TYPES.Sync_MySQLItemRepository)
       .toConstantValue(
-        isSecondaryDatabaseEnabled
-          ? new MongoDBItemRepository(
-              container.get(TYPES.Sync_MongoItemRepository),
-              container.get(TYPES.Sync_MongoDBItemPersistenceMapper),
-              container.get(TYPES.Sync_Logger),
-            )
-          : new TypeORMItemRepository(
-              container.get(TYPES.Sync_ORMItemRepository),
-              container.get(TYPES.Sync_ItemPersistenceMapper),
-              container.get(TYPES.Sync_Logger),
-            ),
+        new TypeORMItemRepository(
+          container.get<Repository<TypeORMItem>>(TYPES.Sync_ORMItemRepository),
+          container.get<MapperInterface<Item, TypeORMItem>>(TYPES.Sync_ItemPersistenceMapper),
+          container.get<Logger>(TYPES.Sync_Logger),
+        ),
+      )
+    container
+      .bind<ItemRepositoryResolverInterface>(TYPES.Sync_ItemRepositoryResolver)
+      .toConstantValue(
+        new TypeORMItemRepositoryResolver(
+          container.get<ItemRepositoryInterface>(TYPES.Sync_MySQLItemRepository),
+          isSecondaryDatabaseEnabled ? container.get<ItemRepositoryInterface>(TYPES.Sync_MongoDBItemRepository) : null,
+        ),
       )
     container
       .bind<SharedVaultRepositoryInterface>(TYPES.Sync_SharedVaultRepository)
@@ -444,10 +471,7 @@ export class ContainerConfigLoader {
     container
       .bind<ItemTransferCalculatorInterface>(TYPES.Sync_ItemTransferCalculator)
       .toDynamicValue((context: interfaces.Context) => {
-        return new ItemTransferCalculator(
-          context.container.get(TYPES.Sync_ItemRepository),
-          context.container.get(TYPES.Sync_Logger),
-        )
+        return new ItemTransferCalculator(context.container.get<Logger>(TYPES.Sync_Logger))
       })
 
     // Middleware
@@ -525,7 +549,7 @@ export class ContainerConfigLoader {
       .bind<GetItems>(TYPES.Sync_GetItems)
       .toConstantValue(
         new GetItems(
-          container.get(TYPES.Sync_ItemRepository),
+          container.get(TYPES.Sync_ItemRepositoryResolver),
           container.get(TYPES.Sync_SharedVaultUserRepository),
           container.get(TYPES.Sync_CONTENT_SIZE_TRANSFER_LIMIT),
           container.get(TYPES.Sync_ItemTransferCalculator),
@@ -537,7 +561,7 @@ export class ContainerConfigLoader {
       .bind<SaveNewItem>(TYPES.Sync_SaveNewItem)
       .toConstantValue(
         new SaveNewItem(
-          container.get(TYPES.Sync_ItemRepository),
+          container.get(TYPES.Sync_ItemRepositoryResolver),
           container.get(TYPES.Sync_Timer),
           container.get(TYPES.Sync_DomainEventPublisher),
           container.get(TYPES.Sync_DomainEventFactory),
@@ -563,7 +587,7 @@ export class ContainerConfigLoader {
       .bind<UpdateExistingItem>(TYPES.Sync_UpdateExistingItem)
       .toConstantValue(
         new UpdateExistingItem(
-          container.get(TYPES.Sync_ItemRepository),
+          container.get(TYPES.Sync_ItemRepositoryResolver),
           container.get(TYPES.Sync_Timer),
           container.get(TYPES.Sync_DomainEventPublisher),
           container.get(TYPES.Sync_DomainEventFactory),
@@ -578,7 +602,7 @@ export class ContainerConfigLoader {
       .toConstantValue(
         new SaveItems(
           container.get(TYPES.Sync_ItemSaveValidator),
-          container.get(TYPES.Sync_ItemRepository),
+          container.get(TYPES.Sync_ItemRepositoryResolver),
           container.get(TYPES.Sync_Timer),
           container.get(TYPES.Sync_SaveNewItem),
           container.get(TYPES.Sync_UpdateExistingItem),
@@ -607,7 +631,7 @@ export class ContainerConfigLoader {
       .bind<SyncItems>(TYPES.Sync_SyncItems)
       .toConstantValue(
         new SyncItems(
-          container.get(TYPES.Sync_ItemRepository),
+          container.get(TYPES.Sync_ItemRepositoryResolver),
           container.get(TYPES.Sync_GetItems),
           container.get(TYPES.Sync_SaveItems),
           container.get(TYPES.Sync_GetSharedVaults),
@@ -617,10 +641,10 @@ export class ContainerConfigLoader {
         ),
       )
     container.bind<CheckIntegrity>(TYPES.Sync_CheckIntegrity).toDynamicValue((context: interfaces.Context) => {
-      return new CheckIntegrity(context.container.get(TYPES.Sync_ItemRepository))
+      return new CheckIntegrity(context.container.get(TYPES.Sync_ItemRepositoryResolver))
     })
     container.bind<GetItem>(TYPES.Sync_GetItem).toDynamicValue((context: interfaces.Context) => {
-      return new GetItem(context.container.get(TYPES.Sync_ItemRepository))
+      return new GetItem(context.container.get(TYPES.Sync_ItemRepositoryResolver))
     })
     container
       .bind<InviteUserToSharedVault>(TYPES.Sync_InviteUserToSharedVault)
@@ -778,48 +802,56 @@ export class ContainerConfigLoader {
         )
       })
 
-    // env vars
     container
-      .bind(TYPES.Sync_EMAIL_ATTACHMENT_MAX_BYTE_SIZE)
-      .toConstantValue(
-        env.get('EMAIL_ATTACHMENT_MAX_BYTE_SIZE', true) ? +env.get('EMAIL_ATTACHMENT_MAX_BYTE_SIZE', true) : 10485760,
-      )
-    container.bind(TYPES.Sync_NEW_RELIC_ENABLED).toConstantValue(env.get('NEW_RELIC_ENABLED', true))
-    container
-      .bind(TYPES.Sync_FILE_UPLOAD_PATH)
+      .bind<ItemBackupServiceInterface>(TYPES.Sync_ItemBackupService)
       .toConstantValue(
-        env.get('FILE_UPLOAD_PATH', true) ? env.get('FILE_UPLOAD_PATH', true) : this.DEFAULT_FILE_UPLOAD_PATH,
+        env.get('S3_AWS_REGION', true)
+          ? new S3ItemBackupService(
+              container.get(TYPES.Sync_S3_BACKUP_BUCKET_NAME),
+              container.get(TYPES.Sync_ItemBackupMapper),
+              container.get(TYPES.Sync_ItemHttpMapper),
+              container.get(TYPES.Sync_Logger),
+              container.get(TYPES.Sync_S3),
+            )
+          : new FSItemBackupService(
+              container.get(TYPES.Sync_FILE_UPLOAD_PATH),
+              container.get(TYPES.Sync_ItemBackupMapper),
+              container.get(TYPES.Sync_Logger),
+            ),
       )
 
     // Handlers
     container
       .bind<DuplicateItemSyncedEventHandler>(TYPES.Sync_DuplicateItemSyncedEventHandler)
-      .toDynamicValue((context: interfaces.Context) => {
-        return new DuplicateItemSyncedEventHandler(
-          context.container.get(TYPES.Sync_ItemRepository),
-          context.container.get(TYPES.Sync_DomainEventFactory),
-          context.container.get(TYPES.Sync_DomainEventPublisher),
-          context.container.get(TYPES.Sync_Logger),
-        )
-      })
+      .toConstantValue(
+        new DuplicateItemSyncedEventHandler(
+          container.get<ItemRepositoryInterface>(TYPES.Sync_MySQLItemRepository),
+          isSecondaryDatabaseEnabled ? container.get<ItemRepositoryInterface>(TYPES.Sync_MongoDBItemRepository) : null,
+          container.get<DomainEventFactoryInterface>(TYPES.Sync_DomainEventFactory),
+          container.get<DomainEventPublisherInterface>(TYPES.Sync_DomainEventPublisher),
+          container.get<Logger>(TYPES.Sync_Logger),
+        ),
+      )
     container
       .bind<AccountDeletionRequestedEventHandler>(TYPES.Sync_AccountDeletionRequestedEventHandler)
-      .toDynamicValue((context: interfaces.Context) => {
-        return new AccountDeletionRequestedEventHandler(
-          context.container.get(TYPES.Sync_ItemRepository),
-          context.container.get(TYPES.Sync_Logger),
-        )
-      })
+      .toConstantValue(
+        new AccountDeletionRequestedEventHandler(
+          container.get<ItemRepositoryInterface>(TYPES.Sync_MySQLItemRepository),
+          isSecondaryDatabaseEnabled ? container.get<ItemRepositoryInterface>(TYPES.Sync_MongoDBItemRepository) : null,
+          container.get<Logger>(TYPES.Sync_Logger),
+        ),
+      )
     container
       .bind<ItemRevisionCreationRequestedEventHandler>(TYPES.Sync_ItemRevisionCreationRequestedEventHandler)
-      .toDynamicValue((context: interfaces.Context) => {
-        return new ItemRevisionCreationRequestedEventHandler(
-          context.container.get(TYPES.Sync_ItemRepository),
-          context.container.get(TYPES.Sync_ItemBackupService),
-          context.container.get(TYPES.Sync_DomainEventFactory),
-          context.container.get(TYPES.Sync_DomainEventPublisher),
-        )
-      })
+      .toConstantValue(
+        new ItemRevisionCreationRequestedEventHandler(
+          container.get<ItemRepositoryInterface>(TYPES.Sync_MySQLItemRepository),
+          isSecondaryDatabaseEnabled ? container.get<ItemRepositoryInterface>(TYPES.Sync_MongoDBItemRepository) : null,
+          container.get<ItemBackupServiceInterface>(TYPES.Sync_ItemBackupService),
+          container.get<DomainEventFactoryInterface>(TYPES.Sync_DomainEventFactory),
+          container.get<DomainEventPublisherInterface>(TYPES.Sync_DomainEventPublisher),
+        ),
+      )
     container
       .bind<SharedVaultFileUploadedEventHandler>(TYPES.Sync_SharedVaultFileUploadedEventHandler)
       .toConstantValue(
@@ -844,38 +876,17 @@ export class ContainerConfigLoader {
     container.bind<AxiosInstance>(TYPES.Sync_HTTPClient).toDynamicValue(() => axios.create())
     container
       .bind<ExtensionsHttpServiceInterface>(TYPES.Sync_ExtensionsHttpService)
-      .toDynamicValue((context: interfaces.Context) => {
-        return new ExtensionsHttpService(
-          context.container.get(TYPES.Sync_HTTPClient),
-          context.container.get(TYPES.Sync_ItemRepository),
-          context.container.get(TYPES.Sync_ContentDecoder),
-          context.container.get(TYPES.Sync_DomainEventPublisher),
-          context.container.get(TYPES.Sync_DomainEventFactory),
-          context.container.get(TYPES.Sync_Logger),
-        )
-      })
-
-    container
-      .bind<ItemBackupServiceInterface>(TYPES.Sync_ItemBackupService)
-      .toDynamicValue((context: interfaces.Context) => {
-        const env: Env = context.container.get(TYPES.Sync_Env)
-
-        if (env.get('S3_AWS_REGION', true)) {
-          return new S3ItemBackupService(
-            context.container.get(TYPES.Sync_S3_BACKUP_BUCKET_NAME),
-            context.container.get(TYPES.Sync_ItemBackupMapper),
-            context.container.get(TYPES.Sync_ItemHttpMapper),
-            context.container.get(TYPES.Sync_Logger),
-            context.container.get(TYPES.Sync_S3),
-          )
-        } else {
-          return new FSItemBackupService(
-            context.container.get(TYPES.Sync_FILE_UPLOAD_PATH),
-            context.container.get(TYPES.Sync_ItemBackupMapper),
-            context.container.get(TYPES.Sync_Logger),
-          )
-        }
-      })
+      .toConstantValue(
+        new ExtensionsHttpService(
+          container.get<AxiosInstance>(TYPES.Sync_HTTPClient),
+          container.get<ItemRepositoryInterface>(TYPES.Sync_MySQLItemRepository),
+          isSecondaryDatabaseEnabled ? container.get<ItemRepositoryInterface>(TYPES.Sync_MongoDBItemRepository) : null,
+          container.get<ContentDecoderInterface>(TYPES.Sync_ContentDecoder),
+          container.get<DomainEventPublisherInterface>(TYPES.Sync_DomainEventPublisher),
+          container.get<DomainEventFactoryInterface>(TYPES.Sync_DomainEventFactory),
+          container.get<Logger>(TYPES.Sync_Logger),
+        ),
+      )
 
     const eventHandlers: Map<string, DomainEventHandlerInterface> = new Map([
       ['DUPLICATE_ITEM_SYNCED', container.get(TYPES.Sync_DuplicateItemSyncedEventHandler)],
@@ -904,19 +915,22 @@ export class ContainerConfigLoader {
 
       container
         .bind<EmailBackupRequestedEventHandler>(TYPES.Sync_EmailBackupRequestedEventHandler)
-        .toDynamicValue((context: interfaces.Context) => {
-          return new EmailBackupRequestedEventHandler(
-            context.container.get(TYPES.Sync_ItemRepository),
-            context.container.get(TYPES.Sync_AuthHttpService),
-            context.container.get(TYPES.Sync_ItemBackupService),
-            context.container.get(TYPES.Sync_DomainEventPublisher),
-            context.container.get(TYPES.Sync_DomainEventFactory),
-            context.container.get(TYPES.Sync_EMAIL_ATTACHMENT_MAX_BYTE_SIZE),
-            context.container.get(TYPES.Sync_ItemTransferCalculator),
-            context.container.get(TYPES.Sync_S3_BACKUP_BUCKET_NAME),
-            context.container.get(TYPES.Sync_Logger),
-          )
-        })
+        .toConstantValue(
+          new EmailBackupRequestedEventHandler(
+            container.get<ItemRepositoryInterface>(TYPES.Sync_MySQLItemRepository),
+            isSecondaryDatabaseEnabled
+              ? container.get<ItemRepositoryInterface>(TYPES.Sync_MongoDBItemRepository)
+              : null,
+            container.get<AuthHttpServiceInterface>(TYPES.Sync_AuthHttpService),
+            container.get<ItemBackupServiceInterface>(TYPES.Sync_ItemBackupService),
+            container.get<DomainEventPublisherInterface>(TYPES.Sync_DomainEventPublisher),
+            container.get<DomainEventFactoryInterface>(TYPES.Sync_DomainEventFactory),
+            container.get<number>(TYPES.Sync_EMAIL_ATTACHMENT_MAX_BYTE_SIZE),
+            container.get<ItemTransferCalculatorInterface>(TYPES.Sync_ItemTransferCalculator),
+            container.get<string>(TYPES.Sync_S3_BACKUP_BUCKET_NAME),
+            container.get<Logger>(TYPES.Sync_Logger),
+          ),
+        )
 
       eventHandlers.set('EMAIL_BACKUP_REQUESTED', container.get(TYPES.Sync_EmailBackupRequestedEventHandler))
     }

+ 4 - 2
packages/syncing-server/src/Bootstrap/Types.ts

@@ -7,7 +7,9 @@ const TYPES = {
   Sync_S3: Symbol.for('Sync_S3'),
   Sync_Env: Symbol.for('Sync_Env'),
   // Repositories
-  Sync_ItemRepository: Symbol.for('Sync_ItemRepository'),
+  Sync_ItemRepositoryResolver: Symbol.for('Sync_ItemRepositoryResolver'),
+  Sync_MySQLItemRepository: Symbol.for('Sync_MySQLItemRepository'),
+  Sync_MongoDBItemRepository: Symbol.for('Sync_MongoDBItemRepository'),
   Sync_SharedVaultRepository: Symbol.for('Sync_SharedVaultRepository'),
   Sync_SharedVaultInviteRepository: Symbol.for('Sync_SharedVaultInviteRepository'),
   Sync_SharedVaultUserRepository: Symbol.for('Sync_SharedVaultUserRepository'),
@@ -23,7 +25,7 @@ const TYPES = {
   Sync_ORMNotificationRepository: Symbol.for('Sync_ORMNotificationRepository'),
   Sync_ORMMessageRepository: Symbol.for('Sync_ORMMessageRepository'),
   // Mongo
-  Sync_MongoItemRepository: Symbol.for('Sync_MongoItemRepository'),
+  Sync_ORMMongoItemRepository: Symbol.for('Sync_ORMMongoItemRepository'),
   // Middleware
   Sync_AuthMiddleware: Symbol.for('Sync_AuthMiddleware'),
   // env vars

+ 33 - 6
packages/syncing-server/src/Domain/Extension/ExtensionsHttpService.spec.ts

@@ -13,7 +13,8 @@ import { Uuid, ContentType, Dates, Timestamps, UniqueEntityId } from '@standardn
 
 describe('ExtensionsHttpService', () => {
   let httpClient: AxiosInstance
-  let itemRepository: ItemRepositoryInterface
+  let primaryItemRepository: ItemRepositoryInterface
+  let secondaryItemRepository: ItemRepositoryInterface | null
   let contentDecoder: ContentDecoderInterface
   let domainEventPublisher: DomainEventPublisherInterface
   let domainEventFactory: DomainEventFactoryInterface
@@ -24,7 +25,8 @@ describe('ExtensionsHttpService', () => {
   const createService = () =>
     new ExtensionsHttpService(
       httpClient,
-      itemRepository,
+      primaryItemRepository,
+      secondaryItemRepository,
       contentDecoder,
       domainEventPublisher,
       domainEventFactory,
@@ -54,8 +56,8 @@ describe('ExtensionsHttpService', () => {
 
     authParams = {} as jest.Mocked<KeyParamsData>
 
-    itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
-    itemRepository.findByUuidAndUserUuid = jest.fn().mockReturnValue(item)
+    primaryItemRepository = {} as jest.Mocked<ItemRepositoryInterface>
+    primaryItemRepository.findByUuidAndUserUuid = jest.fn().mockReturnValue(item)
 
     logger = {} as jest.Mocked<Logger>
     logger.error = jest.fn()
@@ -191,6 +193,31 @@ describe('ExtensionsHttpService', () => {
     expect(domainEventFactory.createEmailRequestedEvent).toHaveBeenCalled()
   })
 
+  it('should publish a failed backup event if the extension is in the secondary repository', async () => {
+    primaryItemRepository.findByUuidAndUserUuid = jest.fn().mockReturnValue(null)
+    secondaryItemRepository = {} as jest.Mocked<ItemRepositoryInterface>
+    secondaryItemRepository.findByUuidAndUserUuid = jest.fn().mockReturnValue(item)
+
+    httpClient.request = jest.fn().mockImplementation(() => {
+      throw new Error('Could not reach the extensions server')
+    })
+
+    await createService().sendItemsToExtensionsServer({
+      userUuid: '1-2-3',
+      extensionId: '2-3-4',
+      extensionsServerUrl: '',
+      forceMute: false,
+      items: [item],
+      backupFilename: 'backup-file',
+      authParams,
+    })
+
+    expect(domainEventPublisher.publish).toHaveBeenCalled()
+    expect(domainEventFactory.createEmailRequestedEvent).toHaveBeenCalled()
+
+    secondaryItemRepository = null
+  })
+
   it('should publish a failed Dropbox backup event if request was sent and extensions server responded not ok', async () => {
     contentDecoder.decode = jest.fn().mockReturnValue({ name: 'Dropbox' })
 
@@ -273,7 +300,7 @@ describe('ExtensionsHttpService', () => {
   })
 
   it('should throw an error if the extension to post to is not found', async () => {
-    itemRepository.findByUuidAndUserUuid = jest.fn().mockReturnValue(null)
+    primaryItemRepository.findByUuidAndUserUuid = jest.fn().mockReturnValue(null)
 
     httpClient.request = jest.fn().mockImplementation(() => {
       throw new Error('Could not reach the extensions server')
@@ -299,7 +326,7 @@ describe('ExtensionsHttpService', () => {
 
   it('should throw an error if the extension to post to has no content', async () => {
     item = {} as jest.Mocked<Item>
-    itemRepository.findByUuidAndUserUuid = jest.fn().mockReturnValue(item)
+    primaryItemRepository.findByUuidAndUserUuid = jest.fn().mockReturnValue(item)
 
     httpClient.request = jest.fn().mockImplementation(() => {
       throw new Error('Could not reach the extensions server')

+ 9 - 3
packages/syncing-server/src/Domain/Extension/ExtensionsHttpService.ts

@@ -17,7 +17,8 @@ import { getBody as oneDriveBody, getSubject as oneDriveSubject } from '../Email
 export class ExtensionsHttpService implements ExtensionsHttpServiceInterface {
   constructor(
     private httpClient: AxiosInstance,
-    private itemRepository: ItemRepositoryInterface,
+    private primaryItemRepository: ItemRepositoryInterface,
+    private secondaryItemRepository: ItemRepositoryInterface | null,
     private contentDecoder: ContentDecoderInterface,
     private domainEventPublisher: DomainEventPublisherInterface,
     private domainEventFactory: DomainEventFactoryInterface,
@@ -139,9 +140,14 @@ export class ExtensionsHttpService implements ExtensionsHttpServiceInterface {
     userUuid: string,
     email: string,
   ): Promise<DomainEventInterface> {
-    const extension = await this.itemRepository.findByUuidAndUserUuid(extensionId, userUuid)
+    let extension = await this.primaryItemRepository.findByUuidAndUserUuid(extensionId, userUuid)
     if (extension === null || !extension.props.content) {
-      throw Error(`Could not find extensions with id ${extensionId}`)
+      if (this.secondaryItemRepository) {
+        extension = await this.secondaryItemRepository.findByUuidAndUserUuid(extensionId, userUuid)
+      }
+      if (extension === null || !extension.props.content) {
+        throw Error(`Could not find extensions with id ${extensionId}`)
+      }
     }
 
     const content = this.contentDecoder.decode(extension.props.content)

+ 19 - 6
packages/syncing-server/src/Domain/Handler/AccountDeletionRequestedEventHandler.spec.ts

@@ -8,12 +8,14 @@ import { AccountDeletionRequestedEventHandler } from './AccountDeletionRequested
 import { Uuid, ContentType, Dates, Timestamps, UniqueEntityId } from '@standardnotes/domain-core'
 
 describe('AccountDeletionRequestedEventHandler', () => {
-  let itemRepository: ItemRepositoryInterface
+  let primaryItemRepository: ItemRepositoryInterface
+  let secondaryItemRepository: ItemRepositoryInterface | null
   let logger: Logger
   let event: AccountDeletionRequestedEvent
   let item: Item
 
-  const createHandler = () => new AccountDeletionRequestedEventHandler(itemRepository, logger)
+  const createHandler = () =>
+    new AccountDeletionRequestedEventHandler(primaryItemRepository, secondaryItemRepository, logger)
 
   beforeEach(() => {
     item = Item.create(
@@ -33,9 +35,9 @@ describe('AccountDeletionRequestedEventHandler', () => {
       new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
     ).getValue()
 
-    itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
-    itemRepository.findAll = jest.fn().mockReturnValue([item])
-    itemRepository.deleteByUserUuid = jest.fn()
+    primaryItemRepository = {} as jest.Mocked<ItemRepositoryInterface>
+    primaryItemRepository.findAll = jest.fn().mockReturnValue([item])
+    primaryItemRepository.deleteByUserUuid = jest.fn()
 
     logger = {} as jest.Mocked<Logger>
     logger.info = jest.fn()
@@ -52,6 +54,17 @@ describe('AccountDeletionRequestedEventHandler', () => {
   it('should remove all items for a user', async () => {
     await createHandler().handle(event)
 
-    expect(itemRepository.deleteByUserUuid).toHaveBeenCalledWith('2-3-4')
+    expect(primaryItemRepository.deleteByUserUuid).toHaveBeenCalledWith('2-3-4')
+  })
+
+  it('should remove all items for a user from secondary repository', async () => {
+    secondaryItemRepository = {} as jest.Mocked<ItemRepositoryInterface>
+    secondaryItemRepository.deleteByUserUuid = jest.fn()
+
+    await createHandler().handle(event)
+
+    expect(secondaryItemRepository.deleteByUserUuid).toHaveBeenCalledWith('2-3-4')
+
+    secondaryItemRepository = null
   })
 })

+ 9 - 2
packages/syncing-server/src/Domain/Handler/AccountDeletionRequestedEventHandler.ts

@@ -3,10 +3,17 @@ import { Logger } from 'winston'
 import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface'
 
 export class AccountDeletionRequestedEventHandler implements DomainEventHandlerInterface {
-  constructor(private itemRepository: ItemRepositoryInterface, private logger: Logger) {}
+  constructor(
+    private primaryItemRepository: ItemRepositoryInterface,
+    private secondaryItemRepository: ItemRepositoryInterface | null,
+    private logger: Logger,
+  ) {}
 
   async handle(event: AccountDeletionRequestedEvent): Promise<void> {
-    await this.itemRepository.deleteByUserUuid(event.payload.userUuid)
+    await this.primaryItemRepository.deleteByUserUuid(event.payload.userUuid)
+    if (this.secondaryItemRepository) {
+      await this.secondaryItemRepository.deleteByUserUuid(event.payload.userUuid)
+    }
 
     this.logger.info(`Finished account cleanup for user: ${event.payload.userUuid}`)
   }

+ 27 - 6
packages/syncing-server/src/Domain/Handler/DuplicateItemSyncedEventHandler.spec.ts

@@ -13,7 +13,8 @@ import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterfac
 import { Uuid, ContentType, Dates, Timestamps, UniqueEntityId } from '@standardnotes/domain-core'
 
 describe('DuplicateItemSyncedEventHandler', () => {
-  let itemRepository: ItemRepositoryInterface
+  let primaryItemRepository: ItemRepositoryInterface
+  let secondaryItemRepository: ItemRepositoryInterface | null
   let logger: Logger
   let duplicateItem: Item
   let originalItem: Item
@@ -22,7 +23,13 @@ describe('DuplicateItemSyncedEventHandler', () => {
   let domainEventPublisher: DomainEventPublisherInterface
 
   const createHandler = () =>
-    new DuplicateItemSyncedEventHandler(itemRepository, domainEventFactory, domainEventPublisher, logger)
+    new DuplicateItemSyncedEventHandler(
+      primaryItemRepository,
+      secondaryItemRepository,
+      domainEventFactory,
+      domainEventPublisher,
+      logger,
+    )
 
   beforeEach(() => {
     originalItem = Item.create(
@@ -59,8 +66,8 @@ describe('DuplicateItemSyncedEventHandler', () => {
       new UniqueEntityId('00000000-0000-0000-0000-000000000001'),
     ).getValue()
 
-    itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
-    itemRepository.findByUuidAndUserUuid = jest
+    primaryItemRepository = {} as jest.Mocked<ItemRepositoryInterface>
+    primaryItemRepository.findByUuidAndUserUuid = jest
       .fn()
       .mockReturnValueOnce(duplicateItem)
       .mockReturnValueOnce(originalItem)
@@ -90,8 +97,22 @@ describe('DuplicateItemSyncedEventHandler', () => {
     expect(domainEventPublisher.publish).toHaveBeenCalled()
   })
 
+  it('should copy revisions from original item to the duplicate item in the secondary repository', async () => {
+    secondaryItemRepository = {} as jest.Mocked<ItemRepositoryInterface>
+    secondaryItemRepository.findByUuidAndUserUuid = jest
+      .fn()
+      .mockReturnValueOnce(duplicateItem)
+      .mockReturnValueOnce(originalItem)
+
+    await createHandler().handle(event)
+
+    expect(domainEventPublisher.publish).toHaveBeenCalledTimes(2)
+
+    secondaryItemRepository = null
+  })
+
   it('should not copy revisions if original item does not exist', async () => {
-    itemRepository.findByUuidAndUserUuid = jest.fn().mockReturnValueOnce(duplicateItem).mockReturnValueOnce(null)
+    primaryItemRepository.findByUuidAndUserUuid = jest.fn().mockReturnValueOnce(duplicateItem).mockReturnValueOnce(null)
 
     await createHandler().handle(event)
 
@@ -99,7 +120,7 @@ describe('DuplicateItemSyncedEventHandler', () => {
   })
 
   it('should not copy revisions if duplicate item does not exist', async () => {
-    itemRepository.findByUuidAndUserUuid = jest.fn().mockReturnValueOnce(null).mockReturnValueOnce(originalItem)
+    primaryItemRepository.findByUuidAndUserUuid = jest.fn().mockReturnValueOnce(null).mockReturnValueOnce(originalItem)
 
     await createHandler().handle(event)
 

+ 15 - 3
packages/syncing-server/src/Domain/Handler/DuplicateItemSyncedEventHandler.ts

@@ -9,14 +9,26 @@ import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface'
 
 export class DuplicateItemSyncedEventHandler implements DomainEventHandlerInterface {
   constructor(
-    private itemRepository: ItemRepositoryInterface,
+    private primaryItemRepository: ItemRepositoryInterface,
+    private secondaryItemRepository: ItemRepositoryInterface | null,
     private domainEventFactory: DomainEventFactoryInterface,
     private domainEventPublisher: DomainEventPublisherInterface,
     private logger: Logger,
   ) {}
 
   async handle(event: DuplicateItemSyncedEvent): Promise<void> {
-    const item = await this.itemRepository.findByUuidAndUserUuid(event.payload.itemUuid, event.payload.userUuid)
+    await this.requestRevisionsCopy(event, this.primaryItemRepository)
+
+    if (this.secondaryItemRepository) {
+      await this.requestRevisionsCopy(event, this.secondaryItemRepository)
+    }
+  }
+
+  private async requestRevisionsCopy(
+    event: DuplicateItemSyncedEvent,
+    itemRepository: ItemRepositoryInterface,
+  ): Promise<void> {
+    const item = await itemRepository.findByUuidAndUserUuid(event.payload.itemUuid, event.payload.userUuid)
 
     if (item === null) {
       this.logger.warn(`Could not find item with uuid ${event.payload.itemUuid}`)
@@ -30,7 +42,7 @@ export class DuplicateItemSyncedEventHandler implements DomainEventHandlerInterf
       return
     }
 
-    const existingOriginalItem = await this.itemRepository.findByUuidAndUserUuid(
+    const existingOriginalItem = await itemRepository.findByUuidAndUserUuid(
       item.props.duplicateOf.value,
       event.payload.userUuid,
     )

+ 25 - 4
packages/syncing-server/src/Domain/Handler/EmailBackupRequestedEventHandler.spec.ts

@@ -13,9 +13,11 @@ import { ItemBackupServiceInterface } from '../Item/ItemBackupServiceInterface'
 import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface'
 import { EmailBackupRequestedEventHandler } from './EmailBackupRequestedEventHandler'
 import { ItemTransferCalculatorInterface } from '../Item/ItemTransferCalculatorInterface'
+import { ItemContentSizeDescriptor } from '../Item/ItemContentSizeDescriptor'
 
 describe('EmailBackupRequestedEventHandler', () => {
-  let itemRepository: ItemRepositoryInterface
+  let primaryItemRepository: ItemRepositoryInterface
+  let secondaryItemRepository: ItemRepositoryInterface | null
   let authHttpService: AuthHttpServiceInterface
   let itemBackupService: ItemBackupServiceInterface
   let domainEventPublisher: DomainEventPublisherInterface
@@ -28,7 +30,8 @@ describe('EmailBackupRequestedEventHandler', () => {
 
   const createHandler = () =>
     new EmailBackupRequestedEventHandler(
-      itemRepository,
+      primaryItemRepository,
+      secondaryItemRepository,
       authHttpService,
       itemBackupService,
       domainEventPublisher,
@@ -42,8 +45,11 @@ describe('EmailBackupRequestedEventHandler', () => {
   beforeEach(() => {
     item = {} as jest.Mocked<Item>
 
-    itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
-    itemRepository.findAll = jest.fn().mockReturnValue([item])
+    primaryItemRepository = {} as jest.Mocked<ItemRepositoryInterface>
+    primaryItemRepository.findAll = jest.fn().mockReturnValue([item])
+    primaryItemRepository.findContentSizeForComputingTransferLimit = jest
+      .fn()
+      .mockResolvedValue([ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000000', 20).getValue()])
 
     authHttpService = {} as jest.Mocked<AuthHttpServiceInterface>
     authHttpService.getUserKeyParams = jest.fn().mockReturnValue({ identifier: 'test@test.com' })
@@ -81,6 +87,21 @@ describe('EmailBackupRequestedEventHandler', () => {
     expect(domainEventFactory.createEmailRequestedEvent).toHaveBeenCalled()
   })
 
+  it('should inform that backup attachment for email was created in the secondary repository', async () => {
+    secondaryItemRepository = {} as jest.Mocked<ItemRepositoryInterface>
+    secondaryItemRepository.findAll = jest.fn().mockReturnValue([item])
+    secondaryItemRepository.findContentSizeForComputingTransferLimit = jest
+      .fn()
+      .mockResolvedValue([ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000000', 20).getValue()])
+
+    await createHandler().handle(event)
+
+    expect(domainEventPublisher.publish).toHaveBeenCalledTimes(2)
+    expect(domainEventFactory.createEmailRequestedEvent).toHaveBeenCalledTimes(2)
+
+    secondaryItemRepository = null
+  })
+
   it('should inform that multipart backup attachment for email was created', async () => {
     itemBackupService.backup = jest
       .fn()

+ 16 - 3
packages/syncing-server/src/Domain/Handler/EmailBackupRequestedEventHandler.ts

@@ -16,7 +16,8 @@ import { getBody, getSubject } from '../Email/EmailBackupAttachmentCreated'
 
 export class EmailBackupRequestedEventHandler implements DomainEventHandlerInterface {
   constructor(
-    private itemRepository: ItemRepositoryInterface,
+    private primaryItemRepository: ItemRepositoryInterface,
+    private secondaryItemRepository: ItemRepositoryInterface | null,
     private authHttpService: AuthHttpServiceInterface,
     private itemBackupService: ItemBackupServiceInterface,
     private domainEventPublisher: DomainEventPublisherInterface,
@@ -28,6 +29,17 @@ export class EmailBackupRequestedEventHandler implements DomainEventHandlerInter
   ) {}
 
   async handle(event: EmailBackupRequestedEvent): Promise<void> {
+    await this.requestEmailWithBackupFile(event, this.primaryItemRepository)
+
+    if (this.secondaryItemRepository) {
+      await this.requestEmailWithBackupFile(event, this.secondaryItemRepository)
+    }
+  }
+
+  private async requestEmailWithBackupFile(
+    event: EmailBackupRequestedEvent,
+    itemRepository: ItemRepositoryInterface,
+  ): Promise<void> {
     let authParams: KeyParamsData
     try {
       authParams = await this.authHttpService.getUserKeyParams({
@@ -46,14 +58,15 @@ export class EmailBackupRequestedEventHandler implements DomainEventHandlerInter
       sortOrder: 'ASC',
       deleted: false,
     }
+    const itemContentSizeDescriptors = await itemRepository.findContentSizeForComputingTransferLimit(itemQuery)
     const itemUuidBundles = await this.itemTransferCalculator.computeItemUuidBundlesToFetch(
-      itemQuery,
+      itemContentSizeDescriptors,
       this.emailAttachmentMaxByteSize,
     )
 
     const backupFileNames: string[] = []
     for (const itemUuidBundle of itemUuidBundles) {
-      const items = await this.itemRepository.findAll({
+      const items = await itemRepository.findAll({
         uuids: itemUuidBundle,
         sortBy: 'updated_at_timestamp',
         sortOrder: 'ASC',

+ 19 - 5
packages/syncing-server/src/Domain/Handler/ItemRevisionCreationRequestedEventHandler.spec.ts

@@ -14,7 +14,8 @@ import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterfac
 import { Uuid, ContentType, Dates, Timestamps, UniqueEntityId } from '@standardnotes/domain-core'
 
 describe('ItemRevisionCreationRequestedEventHandler', () => {
-  let itemRepository: ItemRepositoryInterface
+  let primaryItemRepository: ItemRepositoryInterface
+  let secondaryItemRepository: ItemRepositoryInterface | null
   let event: ItemRevisionCreationRequestedEvent
   let item: Item
   let itemBackupService: ItemBackupServiceInterface
@@ -23,7 +24,8 @@ describe('ItemRevisionCreationRequestedEventHandler', () => {
 
   const createHandler = () =>
     new ItemRevisionCreationRequestedEventHandler(
-      itemRepository,
+      primaryItemRepository,
+      secondaryItemRepository,
       itemBackupService,
       domainEventFactory,
       domainEventPublisher,
@@ -47,8 +49,8 @@ describe('ItemRevisionCreationRequestedEventHandler', () => {
       new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
     ).getValue()
 
-    itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
-    itemRepository.findByUuid = jest.fn().mockReturnValue(item)
+    primaryItemRepository = {} as jest.Mocked<ItemRepositoryInterface>
+    primaryItemRepository.findByUuid = jest.fn().mockReturnValue(item)
 
     event = {} as jest.Mocked<ItemRevisionCreationRequestedEvent>
     event.createdAt = new Date(1)
@@ -80,8 +82,20 @@ describe('ItemRevisionCreationRequestedEventHandler', () => {
     expect(domainEventFactory.createItemDumpedEvent).toHaveBeenCalled()
   })
 
+  it('should create a revision for an item in the secondary repository', async () => {
+    secondaryItemRepository = {} as jest.Mocked<ItemRepositoryInterface>
+    secondaryItemRepository.findByUuid = jest.fn().mockReturnValue(item)
+
+    await createHandler().handle(event)
+
+    expect(domainEventPublisher.publish).toHaveBeenCalled()
+    expect(domainEventFactory.createItemDumpedEvent).toHaveBeenCalled()
+
+    secondaryItemRepository = null
+  })
+
   it('should not create a revision for an item that does not exist', async () => {
-    itemRepository.findByUuid = jest.fn().mockReturnValue(null)
+    primaryItemRepository.findByUuid = jest.fn().mockReturnValue(null)
 
     await createHandler().handle(event)
 

+ 14 - 2
packages/syncing-server/src/Domain/Handler/ItemRevisionCreationRequestedEventHandler.ts

@@ -11,20 +11,32 @@ import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface'
 
 export class ItemRevisionCreationRequestedEventHandler implements DomainEventHandlerInterface {
   constructor(
-    private itemRepository: ItemRepositoryInterface,
+    private primaryItemRepository: ItemRepositoryInterface,
+    private secondaryItemRepository: ItemRepositoryInterface | null,
     private itemBackupService: ItemBackupServiceInterface,
     private domainEventFactory: DomainEventFactoryInterface,
     private domainEventPublisher: DomainEventPublisherInterface,
   ) {}
 
   async handle(event: ItemRevisionCreationRequestedEvent): Promise<void> {
+    await this.createItemDump(event, this.primaryItemRepository)
+
+    if (this.secondaryItemRepository) {
+      await this.createItemDump(event, this.secondaryItemRepository)
+    }
+  }
+
+  private async createItemDump(
+    event: ItemRevisionCreationRequestedEvent,
+    itemRepository: ItemRepositoryInterface,
+  ): Promise<void> {
     const itemUuidOrError = Uuid.create(event.payload.itemUuid)
     if (itemUuidOrError.isFailed()) {
       return
     }
     const itemUuid = itemUuidOrError.getValue()
 
-    const item = await this.itemRepository.findByUuid(itemUuid)
+    const item = await itemRepository.findByUuid(itemUuid)
     if (item === null) {
       return
     }

+ 15 - 0
packages/syncing-server/src/Domain/Item/ItemContentSizeDescriptor.spec.ts

@@ -0,0 +1,15 @@
+import { ItemContentSizeDescriptor } from './ItemContentSizeDescriptor'
+
+describe('ItemContentSizeDescriptor', () => {
+  it('should create a value object', () => {
+    const valueOrError = ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000000', 20)
+
+    expect(valueOrError.isFailed()).toBeFalsy()
+  })
+
+  it('should return error if shared vault uuid is not valid', () => {
+    const valueOrError = ItemContentSizeDescriptor.create('invalid', 20)
+
+    expect(valueOrError.isFailed()).toBeTruthy()
+  })
+})

+ 24 - 0
packages/syncing-server/src/Domain/Item/ItemContentSizeDescriptor.ts

@@ -0,0 +1,24 @@
+import { Result, Uuid, ValueObject } from '@standardnotes/domain-core'
+
+import { ItemContentSizeDescriptorProps } from './ItemContentSizeDescriptorProps'
+
+export class ItemContentSizeDescriptor extends ValueObject<ItemContentSizeDescriptorProps> {
+  private constructor(props: ItemContentSizeDescriptorProps) {
+    super(props)
+  }
+
+  static create(itemUuidString: string, contentSize: number | null): Result<ItemContentSizeDescriptor> {
+    const uuidOrError = Uuid.create(itemUuidString)
+    if (uuidOrError.isFailed()) {
+      return Result.fail<ItemContentSizeDescriptor>(uuidOrError.getError())
+    }
+    const uuid = uuidOrError.getValue()
+
+    return Result.ok<ItemContentSizeDescriptor>(
+      new ItemContentSizeDescriptor({
+        uuid,
+        contentSize,
+      }),
+    )
+  }
+}

+ 6 - 0
packages/syncing-server/src/Domain/Item/ItemContentSizeDescriptorProps.ts

@@ -0,0 +1,6 @@
+import { Uuid } from '@standardnotes/domain-core'
+
+export interface ItemContentSizeDescriptorProps {
+  uuid: Uuid
+  contentSize: number | null
+}

+ 2 - 3
packages/syncing-server/src/Domain/Item/ItemRepositoryInterface.ts

@@ -3,14 +3,13 @@ import { Uuid } from '@standardnotes/domain-core'
 import { Item } from './Item'
 import { ItemQuery } from './ItemQuery'
 import { ExtendedIntegrityPayload } from './ExtendedIntegrityPayload'
+import { ItemContentSizeDescriptor } from './ItemContentSizeDescriptor'
 
 export interface ItemRepositoryInterface {
   deleteByUserUuid(userUuid: string): Promise<void>
   findAll(query: ItemQuery): Promise<Item[]>
   countAll(query: ItemQuery): Promise<number>
-  findContentSizeForComputingTransferLimit(
-    query: ItemQuery,
-  ): Promise<Array<{ uuid: string; contentSize: number | null }>>
+  findContentSizeForComputingTransferLimit(query: ItemQuery): Promise<Array<ItemContentSizeDescriptor>>
   findDatesForComputingIntegrityHash(userUuid: string): Promise<Array<{ updated_at_timestamp: number }>>
   findItemsForComputingIntegrityPayloads(userUuid: string): Promise<ExtendedIntegrityPayload[]>
   findByUuidAndUserUuid(uuid: string, userUuid: string): Promise<Item | null>

+ 7 - 0
packages/syncing-server/src/Domain/Item/ItemRepositoryResolverInterface.ts

@@ -0,0 +1,7 @@
+import { RoleNameCollection } from '@standardnotes/domain-core'
+
+import { ItemRepositoryInterface } from './ItemRepositoryInterface'
+
+export interface ItemRepositoryResolverInterface {
+  resolve(roleNames: RoleNameCollection): ItemRepositoryInterface
+}

+ 88 - 146
packages/syncing-server/src/Domain/Item/ItemTransferCalculator.spec.ts

@@ -1,201 +1,143 @@
 import 'reflect-metadata'
 
 import { Logger } from 'winston'
-import { ItemQuery } from './ItemQuery'
-
-import { ItemRepositoryInterface } from './ItemRepositoryInterface'
 
 import { ItemTransferCalculator } from './ItemTransferCalculator'
+import { ItemContentSizeDescriptor } from './ItemContentSizeDescriptor'
 
 describe('ItemTransferCalculator', () => {
-  let itemRepository: ItemRepositoryInterface
   let logger: Logger
 
-  const createCalculator = () => new ItemTransferCalculator(itemRepository, logger)
+  const createCalculator = () => new ItemTransferCalculator(logger)
 
   beforeEach(() => {
-    itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
-    itemRepository.findContentSizeForComputingTransferLimit = jest.fn().mockReturnValue([])
-
     logger = {} as jest.Mocked<Logger>
     logger.warn = jest.fn()
   })
 
   describe('fetching uuids', () => {
     it('should compute uuids to fetch based on transfer limit - one item overlaping limit', async () => {
-      const query = {} as jest.Mocked<ItemQuery>
-      itemRepository.findContentSizeForComputingTransferLimit = jest.fn().mockReturnValue([
-        {
-          uuid: '1-2-3',
-          contentSize: 20,
-        },
-        {
-          uuid: '2-3-4',
-          contentSize: 20,
-        },
-        {
-          uuid: '3-4-5',
-          contentSize: 20,
-        },
+      const itemContentSizeDescriptors = [
+        ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000000', 20).getValue(),
+        ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000001', 20).getValue(),
+        ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000002', 20).getValue(),
+      ]
+
+      const result = await createCalculator().computeItemUuidsToFetch(itemContentSizeDescriptors, 50)
+
+      expect(result).toEqual([
+        '00000000-0000-0000-0000-000000000000',
+        '00000000-0000-0000-0000-000000000001',
+        '00000000-0000-0000-0000-000000000002',
       ])
-
-      const result = await createCalculator().computeItemUuidsToFetch(query, 50)
-
-      expect(result).toEqual(['1-2-3', '2-3-4', '3-4-5'])
     })
 
     it('should compute uuids to fetch based on transfer limit - exact limit fit', async () => {
-      const query = {} as jest.Mocked<ItemQuery>
-      itemRepository.findContentSizeForComputingTransferLimit = jest.fn().mockReturnValue([
-        {
-          uuid: '1-2-3',
-          contentSize: 20,
-        },
-        {
-          uuid: '2-3-4',
-          contentSize: 20,
-        },
-        {
-          uuid: '3-4-5',
-          contentSize: 20,
-        },
-      ])
+      const itemContentSizeDescriptors = [
+        ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000000', 20).getValue(),
+        ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000001', 20).getValue(),
+        ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000002', 20).getValue(),
+      ]
 
-      const result = await createCalculator().computeItemUuidsToFetch(query, 40)
+      const result = await createCalculator().computeItemUuidsToFetch(itemContentSizeDescriptors, 40)
 
-      expect(result).toEqual(['1-2-3', '2-3-4'])
+      expect(result).toEqual(['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'])
     })
 
     it('should compute uuids to fetch based on transfer limit - content size not defined on an item', async () => {
-      const query = {} as jest.Mocked<ItemQuery>
-      itemRepository.findContentSizeForComputingTransferLimit = jest.fn().mockReturnValue([
-        {
-          uuid: '1-2-3',
-          contentSize: 20,
-        },
-        {
-          uuid: '2-3-4',
-          contentSize: 20,
-        },
-        {
-          uuid: '3-4-5',
-        },
+      const itemContentSizeDescriptors = [
+        ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000000', 20).getValue(),
+        ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000001', 20).getValue(),
+        ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000002', null).getValue(),
+      ]
+
+      const result = await createCalculator().computeItemUuidsToFetch(itemContentSizeDescriptors, 50)
+
+      expect(result).toEqual([
+        '00000000-0000-0000-0000-000000000000',
+        '00000000-0000-0000-0000-000000000001',
+        '00000000-0000-0000-0000-000000000002',
       ])
-
-      const result = await createCalculator().computeItemUuidsToFetch(query, 50)
-
-      expect(result).toEqual(['1-2-3', '2-3-4', '3-4-5'])
     })
 
     it('should compute uuids to fetch based on transfer limit - first item over the limit', async () => {
-      const query = {} as jest.Mocked<ItemQuery>
-      itemRepository.findContentSizeForComputingTransferLimit = jest.fn().mockReturnValue([
-        {
-          uuid: '1-2-3',
-          contentSize: 50,
-        },
-        {
-          uuid: '2-3-4',
-          contentSize: 20,
-        },
-        {
-          uuid: '3-4-5',
-          contentSize: 20,
-        },
-      ])
+      const itemContentSizeDescriptors = [
+        ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000000', 50).getValue(),
+        ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000001', 20).getValue(),
+        ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000002', 20).getValue(),
+      ]
 
-      const result = await createCalculator().computeItemUuidsToFetch(query, 40)
+      const result = await createCalculator().computeItemUuidsToFetch(itemContentSizeDescriptors, 40)
 
-      expect(result).toEqual(['1-2-3', '2-3-4'])
+      expect(result).toEqual(['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'])
     })
   })
 
   describe('fetching bundles', () => {
     it('should compute uuid bundles to fetch based on transfer limit - one item overlaping limit', async () => {
-      const query = {} as jest.Mocked<ItemQuery>
-      itemRepository.findContentSizeForComputingTransferLimit = jest.fn().mockReturnValue([
-        {
-          uuid: '1-2-3',
-          contentSize: 20,
-        },
-        {
-          uuid: '2-3-4',
-          contentSize: 20,
-        },
-        {
-          uuid: '3-4-5',
-          contentSize: 20,
-        },
+      const itemContentSizeDescriptors = [
+        ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000000', 20).getValue(),
+        ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000001', 20).getValue(),
+        ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000002', 20).getValue(),
+      ]
+
+      const result = await createCalculator().computeItemUuidBundlesToFetch(itemContentSizeDescriptors, 50)
+
+      expect(result).toEqual([
+        [
+          '00000000-0000-0000-0000-000000000000',
+          '00000000-0000-0000-0000-000000000001',
+          '00000000-0000-0000-0000-000000000002',
+        ],
       ])
-
-      const result = await createCalculator().computeItemUuidBundlesToFetch(query, 50)
-
-      expect(result).toEqual([['1-2-3', '2-3-4', '3-4-5']])
     })
 
     it('should compute uuid bundles to fetch based on transfer limit - exact limit fit', async () => {
-      const query = {} as jest.Mocked<ItemQuery>
-      itemRepository.findContentSizeForComputingTransferLimit = jest.fn().mockReturnValue([
-        {
-          uuid: '1-2-3',
-          contentSize: 20,
-        },
-        {
-          uuid: '2-3-4',
-          contentSize: 20,
-        },
-        {
-          uuid: '3-4-5',
-          contentSize: 20,
-        },
-      ])
+      const itemContentSizeDescriptors = [
+        ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000000', 20).getValue(),
+        ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000001', 20).getValue(),
+        ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000002', 20).getValue(),
+      ]
 
-      const result = await createCalculator().computeItemUuidBundlesToFetch(query, 40)
+      const result = await createCalculator().computeItemUuidBundlesToFetch(itemContentSizeDescriptors, 40)
 
-      expect(result).toEqual([['1-2-3', '2-3-4'], ['3-4-5']])
+      expect(result).toEqual([
+        ['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'],
+        ['00000000-0000-0000-0000-000000000002'],
+      ])
     })
 
     it('should compute uuid bundles to fetch based on transfer limit - content size not defined on an item', async () => {
-      const query = {} as jest.Mocked<ItemQuery>
-      itemRepository.findContentSizeForComputingTransferLimit = jest.fn().mockReturnValue([
-        {
-          uuid: '1-2-3',
-          contentSize: 20,
-        },
-        {
-          uuid: '2-3-4',
-          contentSize: 20,
-        },
-        {
-          uuid: '3-4-5',
-        },
+      const itemContentSizeDescriptors = [
+        ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000000', 20).getValue(),
+        ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000001', 20).getValue(),
+        ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000002', null).getValue(),
+      ]
+
+      const result = await createCalculator().computeItemUuidBundlesToFetch(itemContentSizeDescriptors, 50)
+
+      expect(result).toEqual([
+        [
+          '00000000-0000-0000-0000-000000000000',
+          '00000000-0000-0000-0000-000000000001',
+          '00000000-0000-0000-0000-000000000002',
+        ],
       ])
-
-      const result = await createCalculator().computeItemUuidBundlesToFetch(query, 50)
-
-      expect(result).toEqual([['1-2-3', '2-3-4', '3-4-5']])
     })
 
     it('should compute uuid bundles to fetch based on transfer limit - first item over the limit', async () => {
-      const query = {} as jest.Mocked<ItemQuery>
-      itemRepository.findContentSizeForComputingTransferLimit = jest.fn().mockReturnValue([
-        {
-          uuid: '1-2-3',
-          contentSize: 50,
-        },
-        {
-          uuid: '2-3-4',
-          contentSize: 20,
-        },
-        {
-          uuid: '3-4-5',
-          contentSize: 20,
-        },
-      ])
+      const itemContentSizeDescriptors = [
+        ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000000', 50).getValue(),
+        ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000001', 20).getValue(),
+        ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000002', 20).getValue(),
+      ]
 
-      const result = await createCalculator().computeItemUuidBundlesToFetch(query, 40)
+      const result = await createCalculator().computeItemUuidBundlesToFetch(itemContentSizeDescriptors, 40)
 
-      expect(result).toEqual([['1-2-3', '2-3-4'], ['3-4-5']])
+      expect(result).toEqual([
+        ['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'],
+        ['00000000-0000-0000-0000-000000000002'],
+      ])
     })
   })
 })

+ 20 - 17
packages/syncing-server/src/Domain/Item/ItemTransferCalculator.ts

@@ -1,27 +1,28 @@
 import { Logger } from 'winston'
 
 import { ItemTransferCalculatorInterface } from './ItemTransferCalculatorInterface'
-import { ItemQuery } from './ItemQuery'
-import { ItemRepositoryInterface } from './ItemRepositoryInterface'
+import { ItemContentSizeDescriptor } from './ItemContentSizeDescriptor'
 
 export class ItemTransferCalculator implements ItemTransferCalculatorInterface {
-  constructor(private itemRepository: ItemRepositoryInterface, private logger: Logger) {}
+  constructor(private logger: Logger) {}
 
-  async computeItemUuidsToFetch(itemQuery: ItemQuery, bytesTransferLimit: number): Promise<Array<string>> {
+  async computeItemUuidsToFetch(
+    itemContentSizeDescriptors: ItemContentSizeDescriptor[],
+    bytesTransferLimit: number,
+  ): Promise<Array<string>> {
     const itemUuidsToFetch = []
-    const itemContentSizes = await this.itemRepository.findContentSizeForComputingTransferLimit(itemQuery)
     let totalContentSizeInBytes = 0
-    for (const itemContentSize of itemContentSizes) {
-      const contentSize = itemContentSize.contentSize ?? 0
+    for (const itemContentSize of itemContentSizeDescriptors) {
+      const contentSize = itemContentSize.props.contentSize ?? 0
 
-      itemUuidsToFetch.push(itemContentSize.uuid)
+      itemUuidsToFetch.push(itemContentSize.props.uuid.value)
       totalContentSizeInBytes += contentSize
 
       const transferLimitBreached = this.isTransferLimitBreached({
         totalContentSizeInBytes,
         bytesTransferLimit,
         itemUuidsToFetch,
-        itemContentSizes,
+        itemContentSizeDescriptors,
       })
 
       if (transferLimitBreached) {
@@ -32,22 +33,24 @@ export class ItemTransferCalculator implements ItemTransferCalculatorInterface {
     return itemUuidsToFetch
   }
 
-  async computeItemUuidBundlesToFetch(itemQuery: ItemQuery, bytesTransferLimit: number): Promise<Array<Array<string>>> {
+  async computeItemUuidBundlesToFetch(
+    itemContentSizeDescriptors: ItemContentSizeDescriptor[],
+    bytesTransferLimit: number,
+  ): Promise<Array<Array<string>>> {
     let itemUuidsToFetch = []
-    const itemContentSizes = await this.itemRepository.findContentSizeForComputingTransferLimit(itemQuery)
     let totalContentSizeInBytes = 0
     const bundles = []
-    for (const itemContentSize of itemContentSizes) {
-      const contentSize = itemContentSize.contentSize ?? 0
+    for (const itemContentSize of itemContentSizeDescriptors) {
+      const contentSize = itemContentSize.props.contentSize ?? 0
 
-      itemUuidsToFetch.push(itemContentSize.uuid)
+      itemUuidsToFetch.push(itemContentSize.props.uuid.value)
       totalContentSizeInBytes += contentSize
 
       const transferLimitBreached = this.isTransferLimitBreached({
         totalContentSizeInBytes,
         bytesTransferLimit,
         itemUuidsToFetch,
-        itemContentSizes,
+        itemContentSizeDescriptors,
       })
 
       if (transferLimitBreached) {
@@ -68,11 +71,11 @@ export class ItemTransferCalculator implements ItemTransferCalculatorInterface {
     totalContentSizeInBytes: number
     bytesTransferLimit: number
     itemUuidsToFetch: Array<string>
-    itemContentSizes: Array<{ uuid: string; contentSize: number | null }>
+    itemContentSizeDescriptors: ItemContentSizeDescriptor[]
   }): boolean {
     const transferLimitBreached = dto.totalContentSizeInBytes >= dto.bytesTransferLimit
     const transferLimitBreachedAtFirstItem =
-      transferLimitBreached && dto.itemUuidsToFetch.length === 1 && dto.itemContentSizes.length > 1
+      transferLimitBreached && dto.itemUuidsToFetch.length === 1 && dto.itemContentSizeDescriptors.length > 1
 
     if (transferLimitBreachedAtFirstItem) {
       this.logger.warn(

+ 9 - 3
packages/syncing-server/src/Domain/Item/ItemTransferCalculatorInterface.ts

@@ -1,6 +1,12 @@
-import { ItemQuery } from './ItemQuery'
+import { ItemContentSizeDescriptor } from './ItemContentSizeDescriptor'
 
 export interface ItemTransferCalculatorInterface {
-  computeItemUuidsToFetch(itemQuery: ItemQuery, bytesTransferLimit: number): Promise<Array<string>>
-  computeItemUuidBundlesToFetch(itemQuery: ItemQuery, bytesTransferLimit: number): Promise<Array<Array<string>>>
+  computeItemUuidsToFetch(
+    itemContentSizeDescriptors: ItemContentSizeDescriptor[],
+    bytesTransferLimit: number,
+  ): Promise<Array<string>>
+  computeItemUuidBundlesToFetch(
+    itemContentSizeDescriptors: ItemContentSizeDescriptor[],
+    bytesTransferLimit: number,
+  ): Promise<Array<Array<string>>>
 }

+ 33 - 2
packages/syncing-server/src/Domain/UseCase/Syncing/CheckIntegrity/CheckIntegrity.spec.ts

@@ -1,15 +1,17 @@
 import 'reflect-metadata'
 
-import { ContentType } from '@standardnotes/domain-core'
+import { ContentType, RoleName } from '@standardnotes/domain-core'
 
 import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
 
 import { CheckIntegrity } from './CheckIntegrity'
+import { ItemRepositoryResolverInterface } from '../../../Item/ItemRepositoryResolverInterface'
 
 describe('CheckIntegrity', () => {
   let itemRepository: ItemRepositoryInterface
+  let itemRepositoryResolver: ItemRepositoryResolverInterface
 
-  const createUseCase = () => new CheckIntegrity(itemRepository)
+  const createUseCase = () => new CheckIntegrity(itemRepositoryResolver)
 
   beforeEach(() => {
     itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
@@ -40,6 +42,9 @@ describe('CheckIntegrity', () => {
         content_type: ContentType.TYPES.File,
       },
     ])
+
+    itemRepositoryResolver = {} as jest.Mocked<ItemRepositoryResolverInterface>
+    itemRepositoryResolver.resolve = jest.fn().mockReturnValue(itemRepository)
   })
 
   it('should return an empty result if there are no integrity mismatches', async () => {
@@ -63,6 +68,7 @@ describe('CheckIntegrity', () => {
           updated_at_timestamp: 5,
         },
       ],
+      roleNames: [RoleName.NAMES.CoreUser],
     })
     expect(result.getValue()).toEqual([])
   })
@@ -88,6 +94,7 @@ describe('CheckIntegrity', () => {
           updated_at_timestamp: 5,
         },
       ],
+      roleNames: [RoleName.NAMES.CoreUser],
     })
     expect(result.getValue()).toEqual([
       {
@@ -114,6 +121,7 @@ describe('CheckIntegrity', () => {
           updated_at_timestamp: 5,
         },
       ],
+      roleNames: [RoleName.NAMES.CoreUser],
     })
     expect(result.getValue()).toEqual([
       {
@@ -122,4 +130,27 @@ describe('CheckIntegrity', () => {
       },
     ])
   })
+
+  it('should return error if the role names are invalid', async () => {
+    const result = await createUseCase().execute({
+      userUuid: '1-2-3',
+      integrityPayloads: [
+        {
+          uuid: '1-2-3',
+          updated_at_timestamp: 1,
+        },
+        {
+          uuid: '2-3-4',
+          updated_at_timestamp: 2,
+        },
+        {
+          uuid: '5-6-7',
+          updated_at_timestamp: 5,
+        },
+      ],
+      roleNames: ['invalid-role-name'],
+    })
+    expect(result.isFailed()).toBeTruthy()
+    expect(result.getError()).toEqual('Invalid role name: invalid-role-name')
+  })
 })

+ 11 - 4
packages/syncing-server/src/Domain/UseCase/Syncing/CheckIntegrity/CheckIntegrity.ts

@@ -1,15 +1,22 @@
 import { IntegrityPayload } from '@standardnotes/responses'
-import { ContentType, Result, UseCaseInterface } from '@standardnotes/domain-core'
+import { ContentType, Result, RoleNameCollection, UseCaseInterface } from '@standardnotes/domain-core'
 
-import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
 import { CheckIntegrityDTO } from './CheckIntegrityDTO'
 import { ExtendedIntegrityPayload } from '../../../Item/ExtendedIntegrityPayload'
+import { ItemRepositoryResolverInterface } from '../../../Item/ItemRepositoryResolverInterface'
 
 export class CheckIntegrity implements UseCaseInterface<IntegrityPayload[]> {
-  constructor(private itemRepository: ItemRepositoryInterface) {}
+  constructor(private itemRepositoryResolver: ItemRepositoryResolverInterface) {}
 
   async execute(dto: CheckIntegrityDTO): Promise<Result<IntegrityPayload[]>> {
-    const serverItemIntegrityPayloads = await this.itemRepository.findItemsForComputingIntegrityPayloads(dto.userUuid)
+    const roleNamesOrError = RoleNameCollection.create(dto.roleNames)
+    if (roleNamesOrError.isFailed()) {
+      return Result.fail(roleNamesOrError.getError())
+    }
+    const roleNames = roleNamesOrError.getValue()
+
+    const itemRepository = this.itemRepositoryResolver.resolve(roleNames)
+    const serverItemIntegrityPayloads = await itemRepository.findItemsForComputingIntegrityPayloads(dto.userUuid)
 
     const serverItemIntegrityPayloadsMap = new Map<string, ExtendedIntegrityPayload>()
     for (const serverItemIntegrityPayload of serverItemIntegrityPayloads) {

+ 1 - 0
packages/syncing-server/src/Domain/UseCase/Syncing/CheckIntegrity/CheckIntegrityDTO.ts

@@ -2,5 +2,6 @@ import { IntegrityPayload } from '@standardnotes/responses'
 
 export type CheckIntegrityDTO = {
   userUuid: string
+  roleNames: string[]
   integrityPayloads: IntegrityPayload[]
 }

+ 19 - 1
packages/syncing-server/src/Domain/UseCase/Syncing/GetItem/GetItem.spec.ts

@@ -3,26 +3,43 @@ import { Item } from '../../../Item/Item'
 import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
 
 import { GetItem } from './GetItem'
+import { ItemRepositoryResolverInterface } from '../../../Item/ItemRepositoryResolverInterface'
+import { RoleName } from '@standardnotes/domain-core'
 
 describe('GetItem', () => {
   let itemRepository: ItemRepositoryInterface
+  let itemRepositoryResolver: ItemRepositoryResolverInterface
 
-  const createUseCase = () => new GetItem(itemRepository)
+  const createUseCase = () => new GetItem(itemRepositoryResolver)
 
   beforeEach(() => {
     itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
     itemRepository.findByUuidAndUserUuid = jest.fn().mockReturnValue(null)
+
+    itemRepositoryResolver = {} as jest.Mocked<ItemRepositoryResolverInterface>
+    itemRepositoryResolver.resolve = jest.fn().mockReturnValue(itemRepository)
   })
 
   it('should fail if item is not found', async () => {
     const result = await createUseCase().execute({
       userUuid: '1-2-3',
       itemUuid: '2-3-4',
+      roleNames: [RoleName.NAMES.CoreUser],
     })
     expect(result.isFailed()).toBeTruthy()
     expect(result.getError()).toEqual('Could not find item with uuid 2-3-4')
   })
 
+  it('should fail if the role names are invalid', async () => {
+    const result = await createUseCase().execute({
+      userUuid: '1-2-3',
+      itemUuid: '2-3-4',
+      roleNames: ['invalid-role-name'],
+    })
+    expect(result.isFailed()).toBeTruthy()
+    expect(result.getError()).toEqual('Invalid role name: invalid-role-name')
+  })
+
   it('should succeed if item is found', async () => {
     const item = {} as jest.Mocked<Item>
     itemRepository.findByUuidAndUserUuid = jest.fn().mockReturnValue(item)
@@ -30,6 +47,7 @@ describe('GetItem', () => {
     const result = await createUseCase().execute({
       userUuid: '1-2-3',
       itemUuid: '2-3-4',
+      roleNames: [RoleName.NAMES.CoreUser],
     })
 
     expect(result.getValue()).toEqual(item)

+ 11 - 4
packages/syncing-server/src/Domain/UseCase/Syncing/GetItem/GetItem.ts

@@ -1,14 +1,21 @@
-import { Result, UseCaseInterface } from '@standardnotes/domain-core'
+import { Result, RoleNameCollection, UseCaseInterface } from '@standardnotes/domain-core'
 
-import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
 import { GetItemDTO } from './GetItemDTO'
 import { Item } from '../../../Item/Item'
+import { ItemRepositoryResolverInterface } from '../../../Item/ItemRepositoryResolverInterface'
 
 export class GetItem implements UseCaseInterface<Item> {
-  constructor(private itemRepository: ItemRepositoryInterface) {}
+  constructor(private itemRepositoryResolver: ItemRepositoryResolverInterface) {}
 
   async execute(dto: GetItemDTO): Promise<Result<Item>> {
-    const item = await this.itemRepository.findByUuidAndUserUuid(dto.itemUuid, dto.userUuid)
+    const roleNamesOrError = RoleNameCollection.create(dto.roleNames)
+    if (roleNamesOrError.isFailed()) {
+      return Result.fail(roleNamesOrError.getError())
+    }
+    const roleNames = roleNamesOrError.getValue()
+
+    const itemRepository = this.itemRepositoryResolver.resolve(roleNames)
+    const item = await itemRepository.findByUuidAndUserUuid(dto.itemUuid, dto.userUuid)
 
     if (item === null) {
       return Result.fail(`Could not find item with uuid ${dto.itemUuid}`)

+ 1 - 0
packages/syncing-server/src/Domain/UseCase/Syncing/GetItem/GetItemDTO.ts

@@ -1,4 +1,5 @@
 export type GetItemDTO = {
   userUuid: string
   itemUuid: string
+  roleNames: string[]
 }

+ 34 - 2
packages/syncing-server/src/Domain/UseCase/Syncing/GetItems/GetItems.spec.ts

@@ -3,11 +3,14 @@ import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
 import { ItemTransferCalculatorInterface } from '../../../Item/ItemTransferCalculatorInterface'
 import { GetItems } from './GetItems'
 import { Item } from '../../../Item/Item'
-import { ContentType, Dates, Timestamps, Uuid } from '@standardnotes/domain-core'
+import { ContentType, Dates, RoleName, Timestamps, Uuid } from '@standardnotes/domain-core'
 import { SharedVaultUserRepositoryInterface } from '../../../SharedVault/User/SharedVaultUserRepositoryInterface'
+import { ItemRepositoryResolverInterface } from '../../../Item/ItemRepositoryResolverInterface'
+import { ItemContentSizeDescriptor } from '../../../Item/ItemContentSizeDescriptor'
 
 describe('GetItems', () => {
   let itemRepository: ItemRepositoryInterface
+  let itemRepositoryResolver: ItemRepositoryResolverInterface
   const contentSizeTransferLimit = 100
   let itemTransferCalculator: ItemTransferCalculatorInterface
   let timer: TimerInterface
@@ -17,7 +20,7 @@ describe('GetItems', () => {
 
   const createUseCase = () =>
     new GetItems(
-      itemRepository,
+      itemRepositoryResolver,
       sharedVaultUserRepository,
       contentSizeTransferLimit,
       itemTransferCalculator,
@@ -43,6 +46,12 @@ describe('GetItems', () => {
     itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
     itemRepository.findAll = jest.fn().mockResolvedValue([item])
     itemRepository.countAll = jest.fn().mockResolvedValue(1)
+    itemRepository.findContentSizeForComputingTransferLimit = jest
+      .fn()
+      .mockResolvedValue([ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000000', 20).getValue()])
+
+    itemRepositoryResolver = {} as jest.Mocked<ItemRepositoryResolverInterface>
+    itemRepositoryResolver.resolve = jest.fn().mockReturnValue(itemRepository)
 
     itemTransferCalculator = {} as jest.Mocked<ItemTransferCalculatorInterface>
     itemTransferCalculator.computeItemUuidsToFetch = jest.fn().mockResolvedValue(['item-uuid'])
@@ -60,6 +69,7 @@ describe('GetItems', () => {
 
     const result = await useCase.execute({
       userUuid: '00000000-0000-0000-0000-000000000000',
+      roleNames: [RoleName.NAMES.CoreUser],
       cursorToken: undefined,
       contentType: undefined,
       limit: 10,
@@ -80,6 +90,7 @@ describe('GetItems', () => {
 
     const result = await useCase.execute({
       userUuid: '00000000-0000-0000-0000-000000000000',
+      roleNames: [RoleName.NAMES.CoreUser],
       cursorToken: undefined,
       contentType: undefined,
       limit: undefined,
@@ -98,6 +109,7 @@ describe('GetItems', () => {
 
     const result = await useCase.execute({
       userUuid: '00000000-0000-0000-0000-000000000000',
+      roleNames: [RoleName.NAMES.CoreUser],
       cursorToken: 'MjowLjAwMDEyMw==',
       contentType: undefined,
       limit: undefined,
@@ -119,6 +131,7 @@ describe('GetItems', () => {
 
     const result = await useCase.execute({
       userUuid: '00000000-0000-0000-0000-000000000000',
+      roleNames: [RoleName.NAMES.CoreUser],
       syncToken,
       contentType: undefined,
       limit: undefined,
@@ -140,6 +153,7 @@ describe('GetItems', () => {
 
     const result = await useCase.execute({
       userUuid: '00000000-0000-0000-0000-000000000000',
+      roleNames: [RoleName.NAMES.CoreUser],
       syncToken,
       contentType: undefined,
       limit: undefined,
@@ -154,6 +168,7 @@ describe('GetItems', () => {
 
     const result = await useCase.execute({
       userUuid: '00000000-0000-0000-0000-000000000000',
+      roleNames: [RoleName.NAMES.CoreUser],
       cursorToken: undefined,
       contentType: undefined,
       limit: 200,
@@ -172,6 +187,7 @@ describe('GetItems', () => {
 
     const result = await useCase.execute({
       userUuid: 'invalid',
+      roleNames: [RoleName.NAMES.CoreUser],
       cursorToken: undefined,
       contentType: undefined,
       limit: undefined,
@@ -181,6 +197,21 @@ describe('GetItems', () => {
     expect(result.getError()).toEqual('Given value is not a valid uuid: invalid')
   })
 
+  it('should return error for invalid role names', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userUuid: '00000000-0000-0000-0000-000000000000',
+      roleNames: ['invalid'],
+      cursorToken: undefined,
+      contentType: undefined,
+      limit: undefined,
+    })
+
+    expect(result.isFailed()).toBeTruthy()
+    expect(result.getError()).toEqual('Invalid role name: invalid')
+  })
+
   it('should filter shared vault uuids user wants to sync with the ones it has access to', async () => {
     sharedVaultUserRepository.findByUserUuid = jest.fn().mockResolvedValue([
       {
@@ -194,6 +225,7 @@ describe('GetItems', () => {
 
     const result = await useCase.execute({
       userUuid: '00000000-0000-0000-0000-000000000000',
+      roleNames: [RoleName.NAMES.CoreUser],
       cursorToken: undefined,
       contentType: undefined,
       limit: undefined,

+ 15 - 6
packages/syncing-server/src/Domain/UseCase/Syncing/GetItems/GetItems.ts

@@ -1,20 +1,20 @@
-import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
+import { Result, RoleNameCollection, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
 import { Time, TimerInterface } from '@standardnotes/time'
 
 import { Item } from '../../../Item/Item'
 import { GetItemsResult } from './GetItemsResult'
 import { ItemQuery } from '../../../Item/ItemQuery'
-import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
 import { ItemTransferCalculatorInterface } from '../../../Item/ItemTransferCalculatorInterface'
 import { GetItemsDTO } from './GetItemsDTO'
 import { SharedVaultUserRepositoryInterface } from '../../../SharedVault/User/SharedVaultUserRepositoryInterface'
+import { ItemRepositoryResolverInterface } from '../../../Item/ItemRepositoryResolverInterface'
 
 export class GetItems implements UseCaseInterface<GetItemsResult> {
   private readonly DEFAULT_ITEMS_LIMIT = 150
   private readonly SYNC_TOKEN_VERSION = 2
 
   constructor(
-    private itemRepository: ItemRepositoryInterface,
+    private itemRepositoryResolver: ItemRepositoryResolverInterface,
     private sharedVaultUserRepository: SharedVaultUserRepositoryInterface,
     private contentSizeTransferLimit: number,
     private itemTransferCalculator: ItemTransferCalculatorInterface,
@@ -35,6 +35,12 @@ export class GetItems implements UseCaseInterface<GetItemsResult> {
     }
     const userUuid = userUuidOrError.getValue()
 
+    const roleNamesOrError = RoleNameCollection.create(dto.roleNames)
+    if (roleNamesOrError.isFailed()) {
+      return Result.fail(roleNamesOrError.getError())
+    }
+    const roleNames = roleNamesOrError.getValue()
+
     const syncTimeComparison = dto.cursorToken ? '>=' : '>'
     const limit = dto.limit === undefined || dto.limit < 1 ? this.DEFAULT_ITEMS_LIMIT : dto.limit
     const upperBoundLimit = limit < this.maxItemsSyncLimit ? limit : this.maxItemsSyncLimit
@@ -59,19 +65,22 @@ export class GetItems implements UseCaseInterface<GetItemsResult> {
       exclusiveSharedVaultUuids,
     }
 
+    const itemRepository = this.itemRepositoryResolver.resolve(roleNames)
+
+    const itemContentSizeDescriptors = await itemRepository.findContentSizeForComputingTransferLimit(itemQuery)
     const itemUuidsToFetch = await this.itemTransferCalculator.computeItemUuidsToFetch(
-      itemQuery,
+      itemContentSizeDescriptors,
       this.contentSizeTransferLimit,
     )
     let items: Array<Item> = []
     if (itemUuidsToFetch.length > 0) {
-      items = await this.itemRepository.findAll({
+      items = await itemRepository.findAll({
         uuids: itemUuidsToFetch,
         sortBy: 'updated_at_timestamp',
         sortOrder: 'ASC',
       })
     }
-    const totalItemsCount = await this.itemRepository.countAll(itemQuery)
+    const totalItemsCount = await itemRepository.countAll(itemQuery)
 
     let cursorToken = undefined
     if (totalItemsCount > upperBoundLimit) {

+ 1 - 0
packages/syncing-server/src/Domain/UseCase/Syncing/GetItems/GetItemsDTO.ts

@@ -1,5 +1,6 @@
 export interface GetItemsDTO {
   userUuid: string
+  roleNames: string[]
   syncToken?: string | null
   cursorToken?: string | null
   limit?: number

+ 36 - 2
packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItems.spec.ts

@@ -5,13 +5,15 @@ import { SaveItems } from './SaveItems'
 import { SaveNewItem } from '../SaveNewItem/SaveNewItem'
 import { UpdateExistingItem } from '../UpdateExistingItem/UpdateExistingItem'
 import { Logger } from 'winston'
-import { ContentType, Dates, Result, Timestamps, Uuid } from '@standardnotes/domain-core'
+import { ContentType, Dates, Result, RoleName, Timestamps, Uuid } from '@standardnotes/domain-core'
 import { ItemHash } from '../../../Item/ItemHash'
 import { Item } from '../../../Item/Item'
+import { ItemRepositoryResolverInterface } from '../../../Item/ItemRepositoryResolverInterface'
 
 describe('SaveItems', () => {
   let itemSaveValidator: ItemSaveValidatorInterface
   let itemRepository: ItemRepositoryInterface
+  let itemRepositoryResolver: ItemRepositoryResolverInterface
   let timer: TimerInterface
   let saveNewItem: SaveNewItem
   let updateExistingItem: UpdateExistingItem
@@ -20,7 +22,7 @@ describe('SaveItems', () => {
   let savedItem: Item
 
   const createUseCase = () =>
-    new SaveItems(itemSaveValidator, itemRepository, timer, saveNewItem, updateExistingItem, logger)
+    new SaveItems(itemSaveValidator, itemRepositoryResolver, timer, saveNewItem, updateExistingItem, logger)
 
   beforeEach(() => {
     itemSaveValidator = {} as jest.Mocked<ItemSaveValidatorInterface>
@@ -29,6 +31,9 @@ describe('SaveItems', () => {
     itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
     itemRepository.findByUuid = jest.fn().mockResolvedValue(null)
 
+    itemRepositoryResolver = {} as jest.Mocked<ItemRepositoryResolverInterface>
+    itemRepositoryResolver.resolve = jest.fn().mockReturnValue(itemRepository)
+
     timer = {} as jest.Mocked<TimerInterface>
     timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123)
 
@@ -83,6 +88,7 @@ describe('SaveItems', () => {
       readOnlyAccess: false,
       sessionUuid: 'session-uuid',
       snjsVersion: '2.200.0',
+      roleNames: [RoleName.NAMES.CoreUser],
     })
 
     expect(result.isFailed()).toBeFalsy()
@@ -91,6 +97,7 @@ describe('SaveItems', () => {
       itemHash: itemHash1,
       userUuid: 'user-uuid',
       sessionUuid: 'session-uuid',
+      roleNames: ['CORE_USER'],
     })
   })
 
@@ -106,6 +113,7 @@ describe('SaveItems', () => {
       readOnlyAccess: false,
       sessionUuid: 'session-uuid',
       snjsVersion: '2.200.0',
+      roleNames: [RoleName.NAMES.CoreUser],
     })
 
     expect(result.isFailed()).toBeFalsy()
@@ -129,6 +137,7 @@ describe('SaveItems', () => {
       readOnlyAccess: false,
       sessionUuid: 'session-uuid',
       snjsVersion: '2.200.0',
+      roleNames: [RoleName.NAMES.CoreUser],
     })
 
     expect(result.isFailed()).toBeFalsy()
@@ -150,6 +159,7 @@ describe('SaveItems', () => {
       readOnlyAccess: true,
       sessionUuid: 'session-uuid',
       snjsVersion: '2.200.0',
+      roleNames: [RoleName.NAMES.CoreUser],
     })
 
     expect(result.isFailed()).toBeFalsy()
@@ -172,6 +182,7 @@ describe('SaveItems', () => {
       readOnlyAccess: false,
       sessionUuid: 'session-uuid',
       snjsVersion: '2.200.0',
+      roleNames: [RoleName.NAMES.CoreUser],
     })
 
     expect(result.isFailed()).toBeFalsy()
@@ -190,6 +201,7 @@ describe('SaveItems', () => {
       readOnlyAccess: false,
       sessionUuid: 'session-uuid',
       snjsVersion: '2.200.0',
+      roleNames: [RoleName.NAMES.CoreUser],
     })
 
     expect(result.isFailed()).toBeFalsy()
@@ -208,6 +220,7 @@ describe('SaveItems', () => {
       readOnlyAccess: false,
       sessionUuid: 'session-uuid',
       snjsVersion: '2.200.0',
+      roleNames: [RoleName.NAMES.CoreUser],
     })
 
     expect(result.isFailed()).toBeFalsy()
@@ -216,6 +229,7 @@ describe('SaveItems', () => {
       existingItem: savedItem,
       sessionUuid: 'session-uuid',
       performingUserUuid: '00000000-0000-0000-0000-000000000000',
+      roleNames: ['CORE_USER'],
     })
   })
 
@@ -232,6 +246,7 @@ describe('SaveItems', () => {
       readOnlyAccess: false,
       sessionUuid: 'session-uuid',
       snjsVersion: '2.200.0',
+      roleNames: [RoleName.NAMES.CoreUser],
     })
 
     expect(result.isFailed()).toBeFalsy()
@@ -256,6 +271,7 @@ describe('SaveItems', () => {
       readOnlyAccess: false,
       sessionUuid: 'session-uuid',
       snjsVersion: '2.200.0',
+      roleNames: [RoleName.NAMES.CoreUser],
     })
 
     expect(result.isFailed()).toBeFalsy()
@@ -301,9 +317,27 @@ describe('SaveItems', () => {
       readOnlyAccess: false,
       sessionUuid: 'session-uuid',
       snjsVersion: '2.200.0',
+      roleNames: [RoleName.NAMES.CoreUser],
     })
 
     expect(result.isFailed()).toBeFalsy()
     expect(result.getValue().syncToken).toEqual('MjowLjAwMDE2')
   })
+
+  it('should fail if the role names are invalid', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      itemHashes: [itemHash1],
+      userUuid: 'user-uuid',
+      apiVersion: '2',
+      readOnlyAccess: false,
+      sessionUuid: 'session-uuid',
+      snjsVersion: '2.200.0',
+      roleNames: ['invalid-role-name'],
+    })
+
+    expect(result.isFailed()).toBeTruthy()
+    expect(result.getError()).toEqual('Invalid role name: invalid-role-name')
+  })
 })

+ 13 - 4
packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItems.ts

@@ -1,4 +1,4 @@
-import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
+import { Result, RoleNameCollection, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
 
 import { SaveItemsResult } from './SaveItemsResult'
 import { SaveItemsDTO } from './SaveItemsDTO'
@@ -7,17 +7,17 @@ import { ItemConflict } from '../../../Item/ItemConflict'
 import { ConflictType } from '@standardnotes/responses'
 import { Time, TimerInterface } from '@standardnotes/time'
 import { Logger } from 'winston'
-import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
 import { ItemSaveValidatorInterface } from '../../../Item/SaveValidator/ItemSaveValidatorInterface'
 import { SaveNewItem } from '../SaveNewItem/SaveNewItem'
 import { UpdateExistingItem } from '../UpdateExistingItem/UpdateExistingItem'
+import { ItemRepositoryResolverInterface } from '../../../Item/ItemRepositoryResolverInterface'
 
 export class SaveItems implements UseCaseInterface<SaveItemsResult> {
   private readonly SYNC_TOKEN_VERSION = 2
 
   constructor(
     private itemSaveValidator: ItemSaveValidatorInterface,
-    private itemRepository: ItemRepositoryInterface,
+    private itemRepositoryResolver: ItemRepositoryResolverInterface,
     private timer: TimerInterface,
     private saveNewItem: SaveNewItem,
     private updateExistingItem: UpdateExistingItem,
@@ -28,6 +28,12 @@ export class SaveItems implements UseCaseInterface<SaveItemsResult> {
     const savedItems: Array<Item> = []
     const conflicts: Array<ItemConflict> = []
 
+    const roleNamesOrError = RoleNameCollection.create(dto.roleNames)
+    if (roleNamesOrError.isFailed()) {
+      return Result.fail(roleNamesOrError.getError())
+    }
+    const roleNames = roleNamesOrError.getValue()
+
     const lastUpdatedTimestamp = this.timer.getTimestampInMicroseconds()
 
     for (const itemHash of dto.itemHashes) {
@@ -42,7 +48,8 @@ export class SaveItems implements UseCaseInterface<SaveItemsResult> {
       }
       const itemUuid = itemUuidOrError.getValue()
 
-      const existingItem = await this.itemRepository.findByUuid(itemUuid)
+      const itemRepository = this.itemRepositoryResolver.resolve(roleNames)
+      const existingItem = await itemRepository.findByUuid(itemUuid)
 
       if (dto.readOnlyAccess) {
         conflicts.push({
@@ -78,6 +85,7 @@ export class SaveItems implements UseCaseInterface<SaveItemsResult> {
           itemHash,
           sessionUuid: dto.sessionUuid,
           performingUserUuid: dto.userUuid,
+          roleNames: dto.roleNames,
         })
         if (udpatedItemOrError.isFailed()) {
           this.logger.error(
@@ -100,6 +108,7 @@ export class SaveItems implements UseCaseInterface<SaveItemsResult> {
             userUuid: dto.userUuid,
             itemHash,
             sessionUuid: dto.sessionUuid,
+            roleNames: dto.roleNames,
           })
           if (newItemOrError.isFailed()) {
             this.logger.error(

+ 1 - 0
packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItemsDTO.ts

@@ -7,4 +7,5 @@ export interface SaveItemsDTO {
   readOnlyAccess: boolean
   sessionUuid: string | null
   snjsVersion: string
+  roleNames: string[]
 }

+ 38 - 2
packages/syncing-server/src/Domain/UseCase/Syncing/SaveNewItem/SaveNewItem.spec.ts

@@ -4,20 +4,22 @@ import { SaveNewItem } from './SaveNewItem'
 import { DomainEventInterface, DomainEventPublisherInterface } from '@standardnotes/domain-events'
 import { DomainEventFactoryInterface } from '../../../Event/DomainEventFactoryInterface'
 import { ItemHash } from '../../../Item/ItemHash'
-import { ContentType, Dates, Result, Timestamps, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
+import { ContentType, Dates, Result, RoleName, Timestamps, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
 import { Item } from '../../../Item/Item'
 import { SharedVaultAssociation } from '../../../SharedVault/SharedVaultAssociation'
 import { KeySystemAssociation } from '../../../KeySystem/KeySystemAssociation'
+import { ItemRepositoryResolverInterface } from '../../../Item/ItemRepositoryResolverInterface'
 
 describe('SaveNewItem', () => {
   let itemRepository: ItemRepositoryInterface
+  let itemRepositoryResolver: ItemRepositoryResolverInterface
   let timer: TimerInterface
   let domainEventPublisher: DomainEventPublisherInterface
   let domainEventFactory: DomainEventFactoryInterface
   let itemHash1: ItemHash
   let item1: Item
 
-  const createUseCase = () => new SaveNewItem(itemRepository, timer, domainEventPublisher, domainEventFactory)
+  const createUseCase = () => new SaveNewItem(itemRepositoryResolver, timer, domainEventPublisher, domainEventFactory)
 
   beforeEach(() => {
     const timeHelper = new Timer()
@@ -62,6 +64,9 @@ describe('SaveNewItem', () => {
     itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
     itemRepository.save = jest.fn()
 
+    itemRepositoryResolver = {} as jest.Mocked<ItemRepositoryResolverInterface>
+    itemRepositoryResolver.resolve = jest.fn().mockReturnValue(itemRepository)
+
     timer = {} as jest.Mocked<TimerInterface>
     timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123456789)
     timer.convertMicrosecondsToDate = jest.fn().mockReturnValue(new Date(123456789))
@@ -85,6 +90,7 @@ describe('SaveNewItem', () => {
 
     const result = await useCase.execute({
       userUuid: '00000000-0000-0000-0000-000000000000',
+      roleNames: [RoleName.NAMES.CoreUser],
       sessionUuid: '00000000-0000-0000-0000-000000000001',
       itemHash: itemHash1,
     })
@@ -106,6 +112,7 @@ describe('SaveNewItem', () => {
 
     const result = await useCase.execute({
       userUuid: '00000000-0000-0000-0000-000000000000',
+      roleNames: [RoleName.NAMES.CoreUser],
       sessionUuid: '00000000-0000-0000-0000-000000000001',
       itemHash: itemHash1,
     })
@@ -125,6 +132,7 @@ describe('SaveNewItem', () => {
 
     const result = await useCase.execute({
       userUuid: '00000000-0000-0000-0000-000000000000',
+      roleNames: [RoleName.NAMES.CoreUser],
       sessionUuid: '00000000-0000-0000-0000-000000000001',
       itemHash: itemHash1,
     })
@@ -142,6 +150,7 @@ describe('SaveNewItem', () => {
 
     const result = await useCase.execute({
       userUuid: '00000000-0000-0000-0000-000000000000',
+      roleNames: [RoleName.NAMES.CoreUser],
       sessionUuid: '00000000-0000-0000-0000-000000000001',
       itemHash: itemHash1,
     })
@@ -161,6 +170,7 @@ describe('SaveNewItem', () => {
 
     const result = await useCase.execute({
       userUuid: '00000000-0000-0000-0000-000000000000',
+      roleNames: [RoleName.NAMES.CoreUser],
       sessionUuid: '00000000-0000-0000-0000-000000000001',
       itemHash: itemHash1,
     })
@@ -180,6 +190,7 @@ describe('SaveNewItem', () => {
 
     const result = await useCase.execute({
       userUuid: '00000000-0000-0000-0000-000000000000',
+      roleNames: [RoleName.NAMES.CoreUser],
       sessionUuid: '00000000-0000-0000-0000-000000000001',
       itemHash: itemHash1,
     })
@@ -196,6 +207,20 @@ describe('SaveNewItem', () => {
       userUuid: '00000000-0000-0000-0000-00000000000',
       sessionUuid: '00000000-0000-0000-0000-000000000001',
       itemHash: itemHash1,
+      roleNames: [RoleName.NAMES.CoreUser],
+    })
+
+    expect(result.isFailed()).toBeTruthy()
+  })
+
+  it('returns a failure if the role names are invalid', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userUuid: '00000000-0000-0000-0000-000000000000',
+      sessionUuid: '00000000-0000-0000-0000-000000000001',
+      itemHash: itemHash1,
+      roleNames: ['invalid'],
     })
 
     expect(result.isFailed()).toBeTruthy()
@@ -206,6 +231,7 @@ describe('SaveNewItem', () => {
 
     const result = await useCase.execute({
       userUuid: '00000000-0000-0000-0000-000000000000',
+      roleNames: [RoleName.NAMES.CoreUser],
       sessionUuid: '00000000-0000-0000-0000-00000000000',
       itemHash: itemHash1,
     })
@@ -223,6 +249,7 @@ describe('SaveNewItem', () => {
 
     const result = await useCase.execute({
       userUuid: '00000000-0000-0000-0000-000000000000',
+      roleNames: [RoleName.NAMES.CoreUser],
       sessionUuid: '00000000-0000-0000-0000-000000000001',
       itemHash: itemHash1,
     })
@@ -240,6 +267,7 @@ describe('SaveNewItem', () => {
 
     const result = await useCase.execute({
       userUuid: '00000000-0000-0000-0000-000000000000',
+      roleNames: [RoleName.NAMES.CoreUser],
       sessionUuid: '00000000-0000-0000-0000-000000000001',
       itemHash: itemHash1,
     })
@@ -257,6 +285,7 @@ describe('SaveNewItem', () => {
 
     const result = await useCase.execute({
       userUuid: '00000000-0000-0000-0000-000000000000',
+      roleNames: [RoleName.NAMES.CoreUser],
       sessionUuid: '00000000-0000-0000-0000-000000000001',
       itemHash: itemHash1,
     })
@@ -276,6 +305,7 @@ describe('SaveNewItem', () => {
 
     const result = await useCase.execute({
       userUuid: '00000000-0000-0000-0000-000000000000',
+      roleNames: [RoleName.NAMES.CoreUser],
       sessionUuid: '00000000-0000-0000-0000-000000000001',
       itemHash: itemHash1,
     })
@@ -297,6 +327,7 @@ describe('SaveNewItem', () => {
       userUuid: '00000000-0000-0000-0000-00000000000',
       sessionUuid: '00000000-0000-0000-0000-000000000001',
       itemHash: itemHash1,
+      roleNames: [RoleName.NAMES.CoreUser],
     })
 
     expect(result.isFailed()).toBeTruthy()
@@ -313,6 +344,7 @@ describe('SaveNewItem', () => {
 
       const result = await useCase.execute({
         userUuid: '00000000-0000-0000-0000-000000000000',
+        roleNames: [RoleName.NAMES.CoreUser],
         sessionUuid: '00000000-0000-0000-0000-000000000001',
         itemHash: itemHash1,
       })
@@ -339,6 +371,7 @@ describe('SaveNewItem', () => {
 
       const result = await useCase.execute({
         userUuid: '00000000-0000-0000-0000-000000000000',
+        roleNames: [RoleName.NAMES.CoreUser],
         sessionUuid: '00000000-0000-0000-0000-000000000001',
         itemHash: itemHash1,
       })
@@ -360,6 +393,7 @@ describe('SaveNewItem', () => {
 
       const result = await useCase.execute({
         userUuid: '00000000-0000-0000-0000-000000000000',
+        roleNames: [RoleName.NAMES.CoreUser],
         sessionUuid: '00000000-0000-0000-0000-000000000001',
         itemHash: itemHash1,
       })
@@ -378,6 +412,7 @@ describe('SaveNewItem', () => {
 
       const result = await useCase.execute({
         userUuid: '00000000-0000-0000-0000-000000000000',
+        roleNames: [RoleName.NAMES.CoreUser],
         sessionUuid: '00000000-0000-0000-0000-000000000001',
         itemHash: itemHash1,
       })
@@ -400,6 +435,7 @@ describe('SaveNewItem', () => {
 
       const result = await useCase.execute({
         userUuid: '00000000-0000-0000-0000-000000000000',
+        roleNames: [RoleName.NAMES.CoreUser],
         sessionUuid: '00000000-0000-0000-0000-000000000001',
         itemHash: itemHash1,
       })

+ 12 - 3
packages/syncing-server/src/Domain/UseCase/Syncing/SaveNewItem/SaveNewItem.ts

@@ -2,6 +2,7 @@ import {
   ContentType,
   Dates,
   Result,
+  RoleNameCollection,
   Timestamps,
   UniqueEntityId,
   UseCaseInterface,
@@ -13,14 +14,14 @@ import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
 
 import { Item } from '../../../Item/Item'
 import { SaveNewItemDTO } from './SaveNewItemDTO'
-import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
 import { DomainEventFactoryInterface } from '../../../Event/DomainEventFactoryInterface'
 import { SharedVaultAssociation } from '../../../SharedVault/SharedVaultAssociation'
 import { KeySystemAssociation } from '../../../KeySystem/KeySystemAssociation'
+import { ItemRepositoryResolverInterface } from '../../../Item/ItemRepositoryResolverInterface'
 
 export class SaveNewItem implements UseCaseInterface<Item> {
   constructor(
-    private itemRepository: ItemRepositoryInterface,
+    private itemRepositoryResolver: ItemRepositoryResolverInterface,
     private timer: TimerInterface,
     private domainEventPublisher: DomainEventPublisherInterface,
     private domainEventFactory: DomainEventFactoryInterface,
@@ -47,6 +48,12 @@ export class SaveNewItem implements UseCaseInterface<Item> {
     }
     const userUuid = userUuidOrError.getValue()
 
+    const roleNamesOrError = RoleNameCollection.create(dto.roleNames)
+    if (roleNamesOrError.isFailed()) {
+      return Result.fail(roleNamesOrError.getError())
+    }
+    const roleNames = roleNamesOrError.getValue()
+
     const contentTypeOrError = ContentType.create(dto.itemHash.props.content_type)
     if (contentTypeOrError.isFailed()) {
       return Result.fail(contentTypeOrError.getError())
@@ -135,7 +142,9 @@ export class SaveNewItem implements UseCaseInterface<Item> {
       newItem.setKeySystemAssociation(keySystemAssociationOrError.getValue())
     }
 
-    await this.itemRepository.save(newItem)
+    const itemRepository = this.itemRepositoryResolver.resolve(roleNames)
+
+    await itemRepository.save(newItem)
 
     if (contentType.value !== null && [ContentType.TYPES.Note, ContentType.TYPES.File].includes(contentType.value)) {
       await this.domainEventPublisher.publish(

+ 1 - 0
packages/syncing-server/src/Domain/UseCase/Syncing/SaveNewItem/SaveNewItemDTO.ts

@@ -2,6 +2,7 @@ import { ItemHash } from '../../../Item/ItemHash'
 
 export interface SaveNewItemDTO {
   userUuid: string
+  roleNames: string[]
   itemHash: ItemHash
   sessionUuid: string | null
 }

+ 36 - 2
packages/syncing-server/src/Domain/UseCase/Syncing/SyncItems/SyncItems.spec.ts

@@ -5,7 +5,7 @@ import { Item } from '../../../Item/Item'
 import { ItemHash } from '../../../Item/ItemHash'
 
 import { SyncItems } from './SyncItems'
-import { ContentType, Dates, Result, Timestamps, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
+import { ContentType, Dates, Result, RoleName, Timestamps, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
 import { GetItems } from '../GetItems/GetItems'
 import { SaveItems } from '../SaveItems/SaveItems'
 import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
@@ -13,11 +13,13 @@ import { GetSharedVaults } from '../../SharedVaults/GetSharedVaults/GetSharedVau
 import { GetMessagesSentToUser } from '../../Messaging/GetMessagesSentToUser/GetMessagesSentToUser'
 import { GetUserNotifications } from '../../Messaging/GetUserNotifications/GetUserNotifications'
 import { GetSharedVaultInvitesSentToUser } from '../../SharedVaults/GetSharedVaultInvitesSentToUser/GetSharedVaultInvitesSentToUser'
+import { ItemRepositoryResolverInterface } from '../../../Item/ItemRepositoryResolverInterface'
 
 describe('SyncItems', () => {
   let getItemsUseCase: GetItems
   let saveItemsUseCase: SaveItems
   let itemRepository: ItemRepositoryInterface
+  let itemRepositoryResolver: ItemRepositoryResolverInterface
   let item1: Item
   let item2: Item
   let item3: Item
@@ -29,7 +31,7 @@ describe('SyncItems', () => {
 
   const createUseCase = () =>
     new SyncItems(
-      itemRepository,
+      itemRepositoryResolver,
       getItemsUseCase,
       saveItemsUseCase,
       getSharedVaultsUseCase,
@@ -122,6 +124,9 @@ describe('SyncItems', () => {
     itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
     itemRepository.findAll = jest.fn().mockReturnValue([item3, item1])
 
+    itemRepositoryResolver = {} as jest.Mocked<ItemRepositoryResolverInterface>
+    itemRepositoryResolver.resolve = jest.fn().mockReturnValue(itemRepository)
+
     getSharedVaultsUseCase = {} as jest.Mocked<GetSharedVaults>
     getSharedVaultsUseCase.execute = jest.fn().mockReturnValue(Result.ok([]))
 
@@ -148,6 +153,7 @@ describe('SyncItems', () => {
       apiVersion: ApiVersion.v20200115,
       sessionUuid: null,
       snjsVersion: '1.2.3',
+      roleNames: [RoleName.NAMES.CoreUser],
     })
     expect(result.getValue()).toEqual({
       conflicts: [],
@@ -167,12 +173,14 @@ describe('SyncItems', () => {
       limit: 10,
       syncToken: 'foo',
       userUuid: '1-2-3',
+      roleNames: ['CORE_USER'],
     })
     expect(saveItemsUseCase.execute).toHaveBeenCalledWith({
       itemHashes: [itemHash],
       userUuid: '1-2-3',
       apiVersion: '20200115',
       snjsVersion: '1.2.3',
+      roleNames: [RoleName.NAMES.CoreUser],
       readOnlyAccess: false,
       sessionUuid: null,
     })
@@ -189,6 +197,7 @@ describe('SyncItems', () => {
       contentType: 'Note',
       apiVersion: ApiVersion.v20200115,
       snjsVersion: '1.2.3',
+      roleNames: [RoleName.NAMES.CoreUser],
     })
     expect(result.getValue()).toEqual({
       conflicts: [],
@@ -214,6 +223,7 @@ describe('SyncItems', () => {
       contentType: 'Note',
       apiVersion: ApiVersion.v20200115,
       snjsVersion: '1.2.3',
+      roleNames: [RoleName.NAMES.CoreUser],
       sharedVaultUuids: ['00000000-0000-0000-0000-000000000000'],
     })
     expect(result.getValue()).toEqual({
@@ -266,6 +276,7 @@ describe('SyncItems', () => {
       contentType: 'Note',
       apiVersion: ApiVersion.v20200115,
       snjsVersion: '1.2.3',
+      roleNames: [RoleName.NAMES.CoreUser],
     })
 
     expect(result.getValue()).toEqual({
@@ -305,6 +316,7 @@ describe('SyncItems', () => {
       contentType: 'Note',
       apiVersion: ApiVersion.v20200115,
       snjsVersion: '1.2.3',
+      roleNames: [RoleName.NAMES.CoreUser],
     })
 
     expect(result.isFailed()).toBeTruthy()
@@ -325,6 +337,24 @@ describe('SyncItems', () => {
       contentType: 'Note',
       apiVersion: ApiVersion.v20200115,
       snjsVersion: '1.2.3',
+      roleNames: [RoleName.NAMES.CoreUser],
+    })
+
+    expect(result.isFailed()).toBeTruthy()
+  })
+
+  it('should return error if role names are invalid', async () => {
+    const result = await createUseCase().execute({
+      userUuid: '1-2-3',
+      itemHashes: [itemHash],
+      computeIntegrityHash: false,
+      limit: 10,
+      readOnlyAccess: false,
+      sessionUuid: '2-3-4',
+      contentType: 'Note',
+      apiVersion: ApiVersion.v20200115,
+      snjsVersion: '1.2.3',
+      roleNames: ['invalid'],
     })
 
     expect(result.isFailed()).toBeTruthy()
@@ -345,6 +375,7 @@ describe('SyncItems', () => {
       contentType: 'Note',
       apiVersion: ApiVersion.v20200115,
       snjsVersion: '1.2.3',
+      roleNames: [RoleName.NAMES.CoreUser],
     })
 
     expect(result.isFailed()).toBeTruthy()
@@ -365,6 +396,7 @@ describe('SyncItems', () => {
       contentType: 'Note',
       apiVersion: ApiVersion.v20200115,
       snjsVersion: '1.2.3',
+      roleNames: [RoleName.NAMES.CoreUser],
     })
 
     expect(result.isFailed()).toBeTruthy()
@@ -385,6 +417,7 @@ describe('SyncItems', () => {
       contentType: 'Note',
       apiVersion: ApiVersion.v20200115,
       snjsVersion: '1.2.3',
+      roleNames: [RoleName.NAMES.CoreUser],
     })
 
     expect(result.isFailed()).toBeTruthy()
@@ -405,6 +438,7 @@ describe('SyncItems', () => {
       contentType: 'Note',
       apiVersion: ApiVersion.v20200115,
       snjsVersion: '1.2.3',
+      roleNames: [RoleName.NAMES.CoreUser],
     })
 
     expect(result.isFailed()).toBeTruthy()

+ 19 - 6
packages/syncing-server/src/Domain/UseCase/Syncing/SyncItems/SyncItems.ts

@@ -1,20 +1,20 @@
-import { ContentType, Result, UseCaseInterface } from '@standardnotes/domain-core'
+import { ContentType, Result, RoleNameCollection, UseCaseInterface } from '@standardnotes/domain-core'
 
 import { Item } from '../../../Item/Item'
 import { ItemConflict } from '../../../Item/ItemConflict'
 import { SyncItemsDTO } from './SyncItemsDTO'
 import { SyncItemsResponse } from './SyncItemsResponse'
-import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
 import { GetItems } from '../GetItems/GetItems'
 import { SaveItems } from '../SaveItems/SaveItems'
 import { GetSharedVaults } from '../../SharedVaults/GetSharedVaults/GetSharedVaults'
 import { GetSharedVaultInvitesSentToUser } from '../../SharedVaults/GetSharedVaultInvitesSentToUser/GetSharedVaultInvitesSentToUser'
 import { GetMessagesSentToUser } from '../../Messaging/GetMessagesSentToUser/GetMessagesSentToUser'
 import { GetUserNotifications } from '../../Messaging/GetUserNotifications/GetUserNotifications'
+import { ItemRepositoryResolverInterface } from '../../../Item/ItemRepositoryResolverInterface'
 
 export class SyncItems implements UseCaseInterface<SyncItemsResponse> {
   constructor(
-    private itemRepository: ItemRepositoryInterface,
+    private itemRepositoryResolver: ItemRepositoryResolverInterface,
     private getItemsUseCase: GetItems,
     private saveItemsUseCase: SaveItems,
     private getSharedVaultsUseCase: GetSharedVaults,
@@ -24,6 +24,12 @@ export class SyncItems implements UseCaseInterface<SyncItemsResponse> {
   ) {}
 
   async execute(dto: SyncItemsDTO): Promise<Result<SyncItemsResponse>> {
+    const roleNamesOrError = RoleNameCollection.create(dto.roleNames)
+    if (roleNamesOrError.isFailed()) {
+      return Result.fail(roleNamesOrError.getError())
+    }
+    const roleNames = roleNamesOrError.getValue()
+
     const getItemsResultOrError = await this.getItemsUseCase.execute({
       userUuid: dto.userUuid,
       syncToken: dto.syncToken,
@@ -31,6 +37,7 @@ export class SyncItems implements UseCaseInterface<SyncItemsResponse> {
       limit: dto.limit,
       contentType: dto.contentType,
       sharedVaultUuids: dto.sharedVaultUuids,
+      roleNames: dto.roleNames,
     })
     if (getItemsResultOrError.isFailed()) {
       return Result.fail(getItemsResultOrError.getError())
@@ -44,6 +51,7 @@ export class SyncItems implements UseCaseInterface<SyncItemsResponse> {
       readOnlyAccess: dto.readOnlyAccess,
       sessionUuid: dto.sessionUuid,
       snjsVersion: dto.snjsVersion,
+      roleNames: dto.roleNames,
     })
     if (saveItemsResultOrError.isFailed()) {
       return Result.fail(saveItemsResultOrError.getError())
@@ -53,7 +61,7 @@ export class SyncItems implements UseCaseInterface<SyncItemsResponse> {
     let retrievedItems = this.filterOutSyncConflictsForConsecutiveSyncs(getItemsResult.items, saveItemsResult.conflicts)
     const isSharedVaultExclusiveSync = dto.sharedVaultUuids && dto.sharedVaultUuids.length > 0
     if (this.isFirstSync(dto) && !isSharedVaultExclusiveSync) {
-      retrievedItems = await this.frontLoadKeysItemsToTop(dto.userUuid, retrievedItems)
+      retrievedItems = await this.frontLoadKeysItemsToTop(dto.userUuid, roleNames, retrievedItems)
     }
 
     const sharedVaultsOrError = await this.getSharedVaultsUseCase.execute({
@@ -125,8 +133,13 @@ export class SyncItems implements UseCaseInterface<SyncItemsResponse> {
     return retrievedItems.filter((item: Item) => syncConflictIds.indexOf(item.id.toString()) === -1)
   }
 
-  private async frontLoadKeysItemsToTop(userUuid: string, retrievedItems: Array<Item>): Promise<Array<Item>> {
-    const itemsKeys = await this.itemRepository.findAll({
+  private async frontLoadKeysItemsToTop(
+    userUuid: string,
+    roleNames: RoleNameCollection,
+    retrievedItems: Array<Item>,
+  ): Promise<Array<Item>> {
+    const itemRepository = this.itemRepositoryResolver.resolve(roleNames)
+    const itemsKeys = await itemRepository.findAll({
       userUuid,
       contentType: ContentType.TYPES.ItemsKey,
       sortBy: 'updated_at_timestamp',

+ 1 - 0
packages/syncing-server/src/Domain/UseCase/Syncing/SyncItems/SyncItemsDTO.ts

@@ -2,6 +2,7 @@ import { ItemHash } from '../../../Item/ItemHash'
 
 export type SyncItemsDTO = {
   userUuid: string
+  roleNames: string[]
   itemHashes: Array<ItemHash>
   computeIntegrityHash: boolean
   limit: number

+ 43 - 1
packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem.spec.ts

@@ -13,6 +13,7 @@ import {
   UniqueEntityId,
   Result,
   NotificationPayload,
+  RoleName,
 } from '@standardnotes/domain-core'
 import { SharedVaultAssociation } from '../../../SharedVault/SharedVaultAssociation'
 import { KeySystemAssociation } from '../../../KeySystem/KeySystemAssociation'
@@ -20,9 +21,11 @@ import { DetermineSharedVaultOperationOnItem } from '../../SharedVaults/Determin
 import { AddNotificationForUser } from '../../Messaging/AddNotificationForUser/AddNotificationForUser'
 import { RemoveNotificationsForUser } from '../../Messaging/RemoveNotificationsForUser/RemoveNotificationsForUser'
 import { SharedVaultOperationOnItem } from '../../../SharedVault/SharedVaultOperationOnItem'
+import { ItemRepositoryResolverInterface } from '../../../Item/ItemRepositoryResolverInterface'
 
 describe('UpdateExistingItem', () => {
   let itemRepository: ItemRepositoryInterface
+  let itemRepositoryResolver: ItemRepositoryResolverInterface
   let timer: TimerInterface
   let domainEventPublisher: DomainEventPublisherInterface
   let domainEventFactory: DomainEventFactoryInterface
@@ -34,7 +37,7 @@ describe('UpdateExistingItem', () => {
 
   const createUseCase = () =>
     new UpdateExistingItem(
-      itemRepository,
+      itemRepositoryResolver,
       timer,
       domainEventPublisher,
       domainEventFactory,
@@ -88,6 +91,9 @@ describe('UpdateExistingItem', () => {
     itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
     itemRepository.save = jest.fn()
 
+    itemRepositoryResolver = {} as jest.Mocked<ItemRepositoryResolverInterface>
+    itemRepositoryResolver.resolve = jest.fn().mockReturnValue(itemRepository)
+
     timer = {} as jest.Mocked<TimerInterface>
     timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123456789)
     timer.convertMicrosecondsToDate = jest.fn().mockReturnValue(new Date(123456789))
@@ -134,6 +140,7 @@ describe('UpdateExistingItem', () => {
       itemHash: itemHash1,
       sessionUuid: '00000000-0000-0000-0000-000000000000',
       performingUserUuid: '00000000-0000-0000-0000-000000000000',
+      roleNames: [RoleName.NAMES.CoreUser],
     })
 
     expect(result.isFailed()).toBeFalsy()
@@ -148,6 +155,21 @@ describe('UpdateExistingItem', () => {
       itemHash: itemHash1,
       sessionUuid: 'invalid-uuid',
       performingUserUuid: '00000000-0000-0000-0000-000000000000',
+      roleNames: [RoleName.NAMES.CoreUser],
+    })
+
+    expect(result.isFailed()).toBeTruthy()
+  })
+
+  it('should return error if role names are invalid', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      existingItem: item1,
+      itemHash: itemHash1,
+      sessionUuid: '00000000-0000-0000-0000-000000000000',
+      performingUserUuid: '00000000-0000-0000-0000-000000000000',
+      roleNames: ['invalid-role'],
     })
 
     expect(result.isFailed()).toBeTruthy()
@@ -164,6 +186,7 @@ describe('UpdateExistingItem', () => {
       }).getValue(),
       sessionUuid: '00000000-0000-0000-0000-000000000000',
       performingUserUuid: '00000000-0000-0000-0000-000000000000',
+      roleNames: [RoleName.NAMES.CoreUser],
     })
 
     expect(result.isFailed()).toBeTruthy()
@@ -180,6 +203,7 @@ describe('UpdateExistingItem', () => {
       }).getValue(),
       sessionUuid: '00000000-0000-0000-0000-000000000000',
       performingUserUuid: '00000000-0000-0000-0000-000000000000',
+      roleNames: [RoleName.NAMES.CoreUser],
     })
 
     expect(result.isFailed()).toBeFalsy()
@@ -203,6 +227,7 @@ describe('UpdateExistingItem', () => {
       }).getValue(),
       sessionUuid: '00000000-0000-0000-0000-000000000000',
       performingUserUuid: '00000000-0000-0000-0000-000000000000',
+      roleNames: [RoleName.NAMES.CoreUser],
     })
 
     expect(result.isFailed()).toBeFalsy()
@@ -221,6 +246,7 @@ describe('UpdateExistingItem', () => {
       }).getValue(),
       sessionUuid: '00000000-0000-0000-0000-000000000000',
       performingUserUuid: '00000000-0000-0000-0000-000000000000',
+      roleNames: [RoleName.NAMES.CoreUser],
     })
 
     expect(result.isFailed()).toBeTruthy()
@@ -238,6 +264,7 @@ describe('UpdateExistingItem', () => {
       }).getValue(),
       sessionUuid: '00000000-0000-0000-0000-000000000000',
       performingUserUuid: '00000000-0000-0000-0000-000000000000',
+      roleNames: [RoleName.NAMES.CoreUser],
     })
 
     expect(result.isFailed()).toBeFalsy()
@@ -256,6 +283,7 @@ describe('UpdateExistingItem', () => {
       }).getValue(),
       sessionUuid: '00000000-0000-0000-0000-000000000000',
       performingUserUuid: '00000000-0000-0000-0000-000000000000',
+      roleNames: [RoleName.NAMES.CoreUser],
     })
 
     expect(result.isFailed()).toBeTruthy()
@@ -278,6 +306,7 @@ describe('UpdateExistingItem', () => {
       }).getValue(),
       sessionUuid: '00000000-0000-0000-0000-000000000000',
       performingUserUuid: '00000000-0000-0000-0000-000000000000',
+      roleNames: [RoleName.NAMES.CoreUser],
     })
 
     expect(result.isFailed()).toBeTruthy()
@@ -302,6 +331,7 @@ describe('UpdateExistingItem', () => {
       }).getValue(),
       sessionUuid: '00000000-0000-0000-0000-000000000000',
       performingUserUuid: '00000000-0000-0000-0000-000000000000',
+      roleNames: [RoleName.NAMES.CoreUser],
     })
 
     expect(result.isFailed()).toBeTruthy()
@@ -316,6 +346,7 @@ describe('UpdateExistingItem', () => {
       itemHash: itemHash1,
       sessionUuid: '00000000-0000-0000-0000-000000000000',
       performingUserUuid: 'invalid-uuid',
+      roleNames: [RoleName.NAMES.CoreUser],
     })
     expect(result.isFailed()).toBeTruthy()
   })
@@ -334,6 +365,7 @@ describe('UpdateExistingItem', () => {
         itemHash,
         sessionUuid: '00000000-0000-0000-0000-000000000000',
         performingUserUuid: '00000000-0000-0000-0000-000000000000',
+        roleNames: [RoleName.NAMES.CoreUser],
       })
       expect(result.isFailed()).toBeFalsy()
       expect(item1.props.sharedVaultAssociation).not.toBeUndefined()
@@ -363,6 +395,7 @@ describe('UpdateExistingItem', () => {
         itemHash,
         sessionUuid: '00000000-0000-0000-0000-000000000000',
         performingUserUuid: '00000000-0000-0000-0000-000000000000',
+        roleNames: [RoleName.NAMES.CoreUser],
       })
 
       expect(result.isFailed()).toBeFalsy()
@@ -389,6 +422,7 @@ describe('UpdateExistingItem', () => {
         itemHash,
         sessionUuid: '00000000-0000-0000-0000-000000000000',
         performingUserUuid: '00000000-0000-0000-0000-000000000000',
+        roleNames: [RoleName.NAMES.CoreUser],
       })
       expect(result.isFailed()).toBeTruthy()
       mock.mockRestore()
@@ -409,6 +443,7 @@ describe('UpdateExistingItem', () => {
         itemHash,
         sessionUuid: '00000000-0000-0000-0000-000000000000',
         performingUserUuid: '00000000-0000-0000-0000-000000000000',
+        roleNames: [RoleName.NAMES.CoreUser],
       })
       expect(result.isFailed()).toBeTruthy()
     })
@@ -440,6 +475,7 @@ describe('UpdateExistingItem', () => {
         itemHash,
         sessionUuid: '00000000-0000-0000-0000-000000000000',
         performingUserUuid: '00000000-0000-0000-0000-000000000000',
+        roleNames: [RoleName.NAMES.CoreUser],
       })
       expect(result.isFailed()).toBeTruthy()
     })
@@ -474,6 +510,7 @@ describe('UpdateExistingItem', () => {
         itemHash,
         sessionUuid: '00000000-0000-0000-0000-000000000000',
         performingUserUuid: '00000000-0000-0000-0000-000000000000',
+        roleNames: [RoleName.NAMES.CoreUser],
       })
       expect(result.isFailed()).toBeTruthy()
 
@@ -495,6 +532,7 @@ describe('UpdateExistingItem', () => {
         itemHash,
         sessionUuid: '00000000-0000-0000-0000-000000000000',
         performingUserUuid: '00000000-0000-0000-0000-000000000000',
+        roleNames: [RoleName.NAMES.CoreUser],
       })
       expect(result.isFailed()).toBeTruthy()
     })
@@ -514,6 +552,7 @@ describe('UpdateExistingItem', () => {
         itemHash,
         sessionUuid: '00000000-0000-0000-0000-000000000000',
         performingUserUuid: '00000000-0000-0000-0000-000000000000',
+        roleNames: [RoleName.NAMES.CoreUser],
       })
       expect(result.isFailed()).toBeFalsy()
       expect(item1.props.keySystemAssociation).not.toBeUndefined()
@@ -540,6 +579,7 @@ describe('UpdateExistingItem', () => {
         itemHash,
         sessionUuid: '00000000-0000-0000-0000-000000000000',
         performingUserUuid: '00000000-0000-0000-0000-000000000000',
+        roleNames: [RoleName.NAMES.CoreUser],
       })
 
       expect(result.isFailed()).toBeFalsy()
@@ -561,6 +601,7 @@ describe('UpdateExistingItem', () => {
         itemHash,
         sessionUuid: '00000000-0000-0000-0000-000000000000',
         performingUserUuid: '00000000-0000-0000-0000-000000000000',
+        roleNames: [RoleName.NAMES.CoreUser],
       })
       expect(result.isFailed()).toBeTruthy()
     })
@@ -583,6 +624,7 @@ describe('UpdateExistingItem', () => {
         itemHash,
         sessionUuid: '00000000-0000-0000-0000-000000000000',
         performingUserUuid: '00000000-0000-0000-0000-000000000000',
+        roleNames: [RoleName.NAMES.CoreUser],
       })
       expect(result.isFailed()).toBeTruthy()
       mock.mockRestore()

+ 12 - 3
packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem.ts

@@ -4,6 +4,7 @@ import {
   NotificationPayload,
   NotificationType,
   Result,
+  RoleNameCollection,
   Timestamps,
   UniqueEntityId,
   UseCaseInterface,
@@ -15,7 +16,6 @@ import { TimerInterface } from '@standardnotes/time'
 
 import { Item } from '../../../Item/Item'
 import { UpdateExistingItemDTO } from './UpdateExistingItemDTO'
-import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
 import { DomainEventFactoryInterface } from '../../../Event/DomainEventFactoryInterface'
 import { SharedVaultAssociation } from '../../../SharedVault/SharedVaultAssociation'
 import { KeySystemAssociation } from '../../../KeySystem/KeySystemAssociation'
@@ -23,10 +23,11 @@ import { DetermineSharedVaultOperationOnItem } from '../../SharedVaults/Determin
 import { SharedVaultOperationOnItem } from '../../../SharedVault/SharedVaultOperationOnItem'
 import { AddNotificationForUser } from '../../Messaging/AddNotificationForUser/AddNotificationForUser'
 import { RemoveNotificationsForUser } from '../../Messaging/RemoveNotificationsForUser/RemoveNotificationsForUser'
+import { ItemRepositoryResolverInterface } from '../../../Item/ItemRepositoryResolverInterface'
 
 export class UpdateExistingItem implements UseCaseInterface<Item> {
   constructor(
-    private itemRepository: ItemRepositoryInterface,
+    private itemRepositoryResolver: ItemRepositoryResolverInterface,
     private timer: TimerInterface,
     private domainEventPublisher: DomainEventPublisherInterface,
     private domainEventFactory: DomainEventFactoryInterface,
@@ -53,6 +54,12 @@ export class UpdateExistingItem implements UseCaseInterface<Item> {
     }
     const userUuid = userUuidOrError.getValue()
 
+    const roleNamesOrError = RoleNameCollection.create(dto.roleNames)
+    if (roleNamesOrError.isFailed()) {
+      return Result.fail(roleNamesOrError.getError())
+    }
+    const roleNames = roleNamesOrError.getValue()
+
     if (dto.itemHash.props.content) {
       dto.existingItem.props.content = dto.itemHash.props.content
     }
@@ -190,7 +197,9 @@ export class UpdateExistingItem implements UseCaseInterface<Item> {
       dto.existingItem.props.itemsKeyId = null
     }
 
-    await this.itemRepository.save(dto.existingItem)
+    const itemRepository = this.itemRepositoryResolver.resolve(roleNames)
+
+    await itemRepository.save(dto.existingItem)
 
     if (secondsFromLastUpdate >= this.revisionFrequency) {
       if (

+ 1 - 0
packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItemDTO.ts

@@ -6,4 +6,5 @@ export interface UpdateExistingItemDTO {
   itemHash: ItemHash
   sessionUuid: string | null
   performingUserUuid: string
+  roleNames: string[]
 }

+ 5 - 1
packages/syncing-server/src/Infra/InversifyExpressUtils/Base/BaseItemsController.ts

@@ -1,6 +1,8 @@
 import { ControllerContainerInterface, MapperInterface, Validator } from '@standardnotes/domain-core'
 import { BaseHttpController, results } from 'inversify-express-utils'
 import { Request, Response } from 'express'
+import { HttpStatusCode } from '@standardnotes/responses'
+import { Role } from '@standardnotes/security'
 
 import { Item } from '../../../Domain/Item/Item'
 import { SyncResponseFactoryResolverInterface } from '../../../Domain/Item/SyncResponse/SyncResponseFactoryResolverInterface'
@@ -8,7 +10,6 @@ import { CheckIntegrity } from '../../../Domain/UseCase/Syncing/CheckIntegrity/C
 import { GetItem } from '../../../Domain/UseCase/Syncing/GetItem/GetItem'
 import { ApiVersion } from '../../../Domain/Api/ApiVersion'
 import { SyncItems } from '../../../Domain/UseCase/Syncing/SyncItems/SyncItems'
-import { HttpStatusCode } from '@standardnotes/responses'
 import { ItemHttpRepresentation } from '../../../Mapping/Http/ItemHttpRepresentation'
 import { ItemHash } from '../../../Domain/Item/ItemHash'
 
@@ -59,6 +60,7 @@ export class BaseItemsController extends BaseHttpController {
 
     const syncResult = await this.syncItems.execute({
       userUuid: response.locals.user.uuid,
+      roleNames: response.locals.roles.map((role: Role) => role.name),
       itemHashes,
       computeIntegrityHash: request.body.compute_integrity === true,
       syncToken: request.body.sync_token,
@@ -91,6 +93,7 @@ export class BaseItemsController extends BaseHttpController {
     const result = await this.checkIntegrity.execute({
       userUuid: response.locals.user.uuid,
       integrityPayloads,
+      roleNames: response.locals.roles.map((role: Role) => role.name),
     })
 
     if (result.isFailed()) {
@@ -106,6 +109,7 @@ export class BaseItemsController extends BaseHttpController {
     const result = await this.getItem.execute({
       userUuid: response.locals.user.uuid,
       itemUuid: request.params.uuid,
+      roleNames: response.locals.roles.map((role: Role) => role.name),
     })
 
     if (result.isFailed()) {

+ 17 - 9
packages/syncing-server/src/Infra/TypeORM/MongoDBItemRepository.ts

@@ -8,6 +8,7 @@ import { Item } from '../../Domain/Item/Item'
 import { ItemQuery } from '../../Domain/Item/ItemQuery'
 import { ItemRepositoryInterface } from '../../Domain/Item/ItemRepositoryInterface'
 import { MongoDBItem } from './MongoDBItem'
+import { ItemContentSizeDescriptor } from '../../Domain/Item/ItemContentSizeDescriptor'
 
 export class MongoDBItemRepository implements ItemRepositoryInterface {
   constructor(
@@ -44,23 +45,30 @@ export class MongoDBItemRepository implements ItemRepositoryInterface {
     return this.mongoRepository.count((options as FindManyOptions<MongoDBItem>).where)
   }
 
-  async findContentSizeForComputingTransferLimit(
-    query: ItemQuery,
-  ): Promise<{ uuid: string; contentSize: number | null }[]> {
+  async findContentSizeForComputingTransferLimit(query: ItemQuery): Promise<ItemContentSizeDescriptor[]> {
     const options = this.createFindOptions(query)
     const rawItems = await this.mongoRepository.find({
       select: ['uuid', 'contentSize'],
       ...options,
     })
 
-    const items = rawItems.map((item) => {
-      return {
-        uuid: item._id.toHexString(),
-        contentSize: item.contentSize,
+    const itemContentSizeDescriptors: ItemContentSizeDescriptor[] = []
+
+    for (const rawItem of rawItems) {
+      const itemContentSizeDescriptorOrError = ItemContentSizeDescriptor.create(
+        rawItem._id.toHexString(),
+        rawItem.contentSize,
+      )
+      if (itemContentSizeDescriptorOrError.isFailed()) {
+        this.logger.error(
+          `Failed to create ItemContentSizeDescriptor for item ${rawItem._id.toHexString()}: ${itemContentSizeDescriptorOrError.getError()}`,
+        )
+        continue
       }
-    })
+      itemContentSizeDescriptors.push(itemContentSizeDescriptorOrError.getValue())
+    }
 
-    return items
+    return itemContentSizeDescriptors
   }
 
   async findDatesForComputingIntegrityHash(userUuid: string): Promise<{ updated_at_timestamp: number }[]> {

+ 17 - 4
packages/syncing-server/src/Infra/TypeORM/TypeORMItemRepository.ts

@@ -7,6 +7,7 @@ import { ItemQuery } from '../../Domain/Item/ItemQuery'
 import { ItemRepositoryInterface } from '../../Domain/Item/ItemRepositoryInterface'
 import { ExtendedIntegrityPayload } from '../../Domain/Item/ExtendedIntegrityPayload'
 import { TypeORMItem } from './TypeORMItem'
+import { ItemContentSizeDescriptor } from '../../Domain/Item/ItemContentSizeDescriptor'
 
 export class TypeORMItemRepository implements ItemRepositoryInterface {
   constructor(
@@ -38,16 +39,28 @@ export class TypeORMItemRepository implements ItemRepositoryInterface {
       .execute()
   }
 
-  async findContentSizeForComputingTransferLimit(
-    query: ItemQuery,
-  ): Promise<{ uuid: string; contentSize: number | null }[]> {
+  async findContentSizeForComputingTransferLimit(query: ItemQuery): Promise<ItemContentSizeDescriptor[]> {
     const queryBuilder = this.createFindAllQueryBuilder(query)
     queryBuilder.select('item.uuid', 'uuid')
     queryBuilder.addSelect('item.content_size', 'contentSize')
 
     const items = await queryBuilder.getRawMany()
 
-    return items
+    const itemContentSizeDescriptors: ItemContentSizeDescriptor[] = []
+    for (const item of items) {
+      const ItemContentSizeDescriptorOrError = ItemContentSizeDescriptor.create(item.uuid, item.contentSize)
+      if (ItemContentSizeDescriptorOrError.isFailed()) {
+        this.logger.error(
+          `Failed to create ItemContentSizeDescriptor for item ${
+            item.uuid
+          }: ${ItemContentSizeDescriptorOrError.getError()}`,
+        )
+        continue
+      }
+      itemContentSizeDescriptors.push(ItemContentSizeDescriptorOrError.getValue())
+    }
+
+    return itemContentSizeDescriptors
   }
 
   async deleteByUserUuid(userUuid: string): Promise<void> {

+ 25 - 0
packages/syncing-server/src/Infra/TypeORM/TypeORMItemRepositoryResolver.ts

@@ -0,0 +1,25 @@
+import { RoleName, RoleNameCollection } from '@standardnotes/domain-core'
+
+import { ItemRepositoryInterface } from '../../Domain/Item/ItemRepositoryInterface'
+import { ItemRepositoryResolverInterface } from '../../Domain/Item/ItemRepositoryResolverInterface'
+
+export class TypeORMItemRepositoryResolver implements ItemRepositoryResolverInterface {
+  constructor(
+    private mysqlItemRepository: ItemRepositoryInterface,
+    private mongoDbItemRepository: ItemRepositoryInterface | null,
+  ) {}
+
+  resolve(roleNames: RoleNameCollection): ItemRepositoryInterface {
+    if (!this.mongoDbItemRepository) {
+      return this.mysqlItemRepository
+    }
+
+    const transitionRoleName = RoleName.create(RoleName.NAMES.TransitionUser).getValue()
+
+    if (roleNames.includes(transitionRoleName)) {
+      return this.mongoDbItemRepository
+    }
+
+    return this.mysqlItemRepository
+  }
+}