瀏覽代碼

feat(syncing-server): add use case for migrating items from one database to another (#701)

Karol Sójko 1 年之前
父節點
當前提交
032fcb938d

+ 10 - 0
packages/syncing-server/src/Bootstrap/Container.ts

@@ -156,6 +156,7 @@ import { MongoDBItemPersistenceMapper } from '../Mapping/Persistence/MongoDB/Mon
 import { Logger } from 'winston'
 import { ItemRepositoryResolverInterface } from '../Domain/Item/ItemRepositoryResolverInterface'
 import { TypeORMItemRepositoryResolver } from '../Infra/TypeORM/TypeORMItemRepositoryResolver'
+import { TransitionItemsFromPrimaryToSecondaryDatabaseForUser } from '../Domain/UseCase/Transition/TransitionItemsFromPrimaryToSecondaryDatabaseForUser/TransitionItemsFromPrimaryToSecondaryDatabaseForUser'
 
 export class ContainerConfigLoader {
   private readonly DEFAULT_CONTENT_SIZE_TRANSFER_LIMIT = 10_000_000
@@ -775,6 +776,15 @@ export class ContainerConfigLoader {
           container.get<SharedVaultRepositoryInterface>(TYPES.Sync_SharedVaultRepository),
         ),
       )
+    container
+      .bind(TransitionItemsFromPrimaryToSecondaryDatabaseForUser)
+      .toConstantValue(
+        new TransitionItemsFromPrimaryToSecondaryDatabaseForUser(
+          container.get<ItemRepositoryInterface>(TYPES.Sync_MySQLItemRepository),
+          isSecondaryDatabaseEnabled ? container.get<ItemRepositoryInterface>(TYPES.Sync_MongoDBItemRepository) : null,
+          container.get<Logger>(TYPES.Sync_Logger),
+        ),
+      )
 
     // Services
     container

+ 3 - 0
packages/syncing-server/src/Bootstrap/Types.ts

@@ -82,6 +82,9 @@ const TYPES = {
   Sync_DetermineSharedVaultOperationOnItem: Symbol.for('Sync_DetermineSharedVaultOperationOnItem'),
   Sync_UpdateStorageQuotaUsedInSharedVault: Symbol.for('Sync_UpdateStorageQuotaUsedInSharedVault'),
   Sync_AddNotificationsForUsers: Symbol.for('Sync_AddNotificationsForUsers'),
+  Sync_TransitionItemsFromPrimaryToSecondaryDatabaseForUser: Symbol.for(
+    'Sync_TransitionItemsFromPrimaryToSecondaryDatabaseForUser',
+  ),
   // Handlers
   Sync_AccountDeletionRequestedEventHandler: Symbol.for('Sync_AccountDeletionRequestedEventHandler'),
   Sync_DuplicateItemSyncedEventHandler: Symbol.for('Sync_DuplicateItemSyncedEventHandler'),

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

@@ -237,4 +237,118 @@ describe('Item', () => {
     expect(entity.props.keySystemAssociation).toBeUndefined()
     expect(entity.getChanges()).toHaveLength(1)
   })
+
+  it('should tell if an item is identical to another item', () => {
+    const entity = Item.create(
+      {
+        duplicateOf: null,
+        itemsKeyId: 'items-key-id',
+        content: 'content',
+        contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
+        encItemKey: 'enc-item-key',
+        authHash: 'auth-hash',
+        userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+        deleted: false,
+        updatedWithSession: null,
+        dates: Dates.create(new Date(123), new Date(123)).getValue(),
+        timestamps: Timestamps.create(123, 123).getValue(),
+      },
+      new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
+    ).getValue()
+
+    const otherEntity = Item.create(
+      {
+        duplicateOf: null,
+        itemsKeyId: 'items-key-id',
+        content: 'content',
+        contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
+        encItemKey: 'enc-item-key',
+        authHash: 'auth-hash',
+        userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+        deleted: false,
+        updatedWithSession: null,
+        dates: Dates.create(new Date(123), new Date(123)).getValue(),
+        timestamps: Timestamps.create(123, 123).getValue(),
+      },
+      new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
+    ).getValue()
+
+    expect(entity.isIdenticalTo(otherEntity)).toBeTruthy()
+  })
+
+  it('should tell that an item is not identical to another item', () => {
+    const entity = Item.create(
+      {
+        duplicateOf: null,
+        itemsKeyId: 'items-key-id',
+        content: 'content',
+        contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
+        encItemKey: 'enc-item-key',
+        authHash: 'auth-hash',
+        userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+        deleted: false,
+        updatedWithSession: null,
+        dates: Dates.create(new Date(123), new Date(123)).getValue(),
+        timestamps: Timestamps.create(123, 123).getValue(),
+      },
+      new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
+    ).getValue()
+
+    const otherEntity = Item.create(
+      {
+        duplicateOf: null,
+        itemsKeyId: 'items-key-id',
+        content: 'content',
+        contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
+        encItemKey: 'enc-item-key',
+        authHash: 'auth-hash',
+        userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+        deleted: false,
+        updatedWithSession: null,
+        dates: Dates.create(new Date(123), new Date(124)).getValue(),
+        timestamps: Timestamps.create(123, 123).getValue(),
+      },
+      new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
+    ).getValue()
+
+    expect(entity.isIdenticalTo(otherEntity)).toBeFalsy()
+  })
+
+  it('should tell that an item is not identical to another item if their ids are different', () => {
+    const entity = Item.create(
+      {
+        duplicateOf: null,
+        itemsKeyId: 'items-key-id',
+        content: 'content',
+        contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
+        encItemKey: 'enc-item-key',
+        authHash: 'auth-hash',
+        userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+        deleted: false,
+        updatedWithSession: null,
+        dates: Dates.create(new Date(123), new Date(123)).getValue(),
+        timestamps: Timestamps.create(123, 123).getValue(),
+      },
+      new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
+    ).getValue()
+
+    const otherEntity = Item.create(
+      {
+        duplicateOf: null,
+        itemsKeyId: 'items-key-id',
+        content: 'content',
+        contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
+        encItemKey: 'enc-item-key',
+        authHash: 'auth-hash',
+        userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+        deleted: false,
+        updatedWithSession: null,
+        dates: Dates.create(new Date(123), new Date(124)).getValue(),
+        timestamps: Timestamps.create(123, 123).getValue(),
+      },
+      new UniqueEntityId('00000000-0000-0000-0000-000000000001'),
+    ).getValue()
+
+    expect(entity.isIdenticalTo(otherEntity)).toBeFalsy()
+  })
 })

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

@@ -108,4 +108,18 @@ export class Item extends Aggregate<ItemProps> {
     )
     this.props.keySystemAssociation = undefined
   }
+
+  isIdenticalTo(item: Item): boolean {
+    if (this._id.toString() !== item._id.toString()) {
+      return false
+    }
+
+    const stringifiedThis = JSON.stringify(this.props)
+    const stringifiedItem = JSON.stringify(item.props)
+
+    const base64This = Buffer.from(stringifiedThis).toString('base64')
+    const base64Item = Buffer.from(stringifiedItem).toString('base64')
+
+    return base64This === base64Item
+  }
 }

+ 358 - 0
packages/syncing-server/src/Domain/UseCase/Transition/TransitionItemsFromPrimaryToSecondaryDatabaseForUser/TransitionItemsFromPrimaryToSecondaryDatabaseForUser.spec.ts

@@ -0,0 +1,358 @@
+import { Logger } from 'winston'
+
+import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
+import { TransitionItemsFromPrimaryToSecondaryDatabaseForUser } from './TransitionItemsFromPrimaryToSecondaryDatabaseForUser'
+import { Item } from '../../../Item/Item'
+import { ContentType, Dates, Timestamps, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
+
+describe('TransitionItemsFromPrimaryToSecondaryDatabaseForUser', () => {
+  let primaryItemRepository: ItemRepositoryInterface
+  let secondaryItemRepository: ItemRepositoryInterface | null
+  let logger: Logger
+  let primaryItem1: Item
+  let primaryItem2: Item
+  let secondaryItem1: Item
+  let secondaryItem2: Item
+
+  const createUseCase = () =>
+    new TransitionItemsFromPrimaryToSecondaryDatabaseForUser(primaryItemRepository, secondaryItemRepository, logger)
+
+  beforeEach(() => {
+    primaryItem1 = Item.create(
+      {
+        duplicateOf: null,
+        itemsKeyId: 'items-key-id=1',
+        content: 'content-1',
+        contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
+        encItemKey: 'enc-item-key-1',
+        authHash: 'auth-hash-1',
+        userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+        deleted: false,
+        updatedWithSession: null,
+        dates: Dates.create(new Date(123), new Date(123)).getValue(),
+        timestamps: Timestamps.create(123, 123).getValue(),
+      },
+      new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
+    ).getValue()
+
+    primaryItem2 = Item.create(
+      {
+        duplicateOf: null,
+        itemsKeyId: 'items-key-id=2',
+        content: 'content-2',
+        contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
+        encItemKey: 'enc-item-key-2',
+        authHash: 'auth-hash-2',
+        userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+        deleted: true,
+        updatedWithSession: null,
+        dates: Dates.create(new Date(123), new Date(123)).getValue(),
+        timestamps: Timestamps.create(123, 123).getValue(),
+      },
+      new UniqueEntityId('00000000-0000-0000-0000-000000000001'),
+    ).getValue()
+
+    secondaryItem1 = Item.create(
+      {
+        duplicateOf: null,
+        itemsKeyId: 'items-key-id=1',
+        content: 'content-1',
+        contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
+        encItemKey: 'enc-item-key-1',
+        authHash: 'auth-hash-1',
+        userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+        deleted: false,
+        updatedWithSession: null,
+        dates: Dates.create(new Date(123), new Date(123)).getValue(),
+        timestamps: Timestamps.create(123, 123).getValue(),
+      },
+      new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
+    ).getValue()
+
+    secondaryItem2 = Item.create(
+      {
+        duplicateOf: null,
+        itemsKeyId: 'items-key-id=2',
+        content: 'content-2',
+        contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
+        encItemKey: 'enc-item-key-2',
+        authHash: 'auth-hash-2',
+        userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+        deleted: true,
+        updatedWithSession: null,
+        dates: Dates.create(new Date(123), new Date(123)).getValue(),
+        timestamps: Timestamps.create(123, 123).getValue(),
+      },
+      new UniqueEntityId('00000000-0000-0000-0000-000000000001'),
+    ).getValue()
+
+    primaryItemRepository = {} as jest.Mocked<ItemRepositoryInterface>
+    primaryItemRepository.countAll = jest.fn().mockResolvedValue(2)
+    primaryItemRepository.findAll = jest
+      .fn()
+      .mockResolvedValueOnce([primaryItem1])
+      .mockResolvedValueOnce([primaryItem2])
+      .mockResolvedValueOnce([primaryItem1])
+      .mockResolvedValueOnce([primaryItem2])
+    primaryItemRepository.deleteByUserUuid = jest.fn().mockResolvedValue(undefined)
+
+    secondaryItemRepository = {} as jest.Mocked<ItemRepositoryInterface>
+    secondaryItemRepository.save = jest.fn().mockResolvedValue(undefined)
+    secondaryItemRepository.deleteByUserUuid = jest.fn().mockResolvedValue(undefined)
+    secondaryItemRepository.countAll = jest.fn().mockResolvedValue(2)
+    secondaryItemRepository.findByUuid = jest
+      .fn()
+      .mockResolvedValueOnce(secondaryItem1)
+      .mockResolvedValueOnce(secondaryItem2)
+
+    logger = {} as jest.Mocked<Logger>
+    logger.error = jest.fn()
+  })
+
+  describe('successfull transition', () => {
+    it('should transition items from primary to secondary database', async () => {
+      const useCase = createUseCase()
+
+      const result = await useCase.execute({
+        userUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.isFailed()).toBeFalsy()
+
+      expect(primaryItemRepository.countAll).toHaveBeenCalledTimes(2)
+      expect(primaryItemRepository.countAll).toHaveBeenCalledWith({ userUuid: '00000000-0000-0000-0000-000000000000' })
+      expect(primaryItemRepository.findAll).toHaveBeenCalledTimes(4)
+      expect(primaryItemRepository.findAll).toHaveBeenNthCalledWith(1, {
+        userUuid: '00000000-0000-0000-0000-000000000000',
+        limit: 1,
+        offset: 0,
+      })
+      expect(primaryItemRepository.findAll).toHaveBeenNthCalledWith(2, {
+        userUuid: '00000000-0000-0000-0000-000000000000',
+        limit: 1,
+        offset: 1,
+      })
+      expect(primaryItemRepository.findAll).toHaveBeenNthCalledWith(3, {
+        userUuid: '00000000-0000-0000-0000-000000000000',
+        limit: 1,
+        offset: 0,
+      })
+      expect(primaryItemRepository.findAll).toHaveBeenNthCalledWith(4, {
+        userUuid: '00000000-0000-0000-0000-000000000000',
+        limit: 1,
+        offset: 1,
+      })
+      expect((secondaryItemRepository as ItemRepositoryInterface).save).toHaveBeenCalledTimes(2)
+      expect((secondaryItemRepository as ItemRepositoryInterface).save).toHaveBeenCalledWith(primaryItem1)
+      expect((secondaryItemRepository as ItemRepositoryInterface).save).toHaveBeenCalledWith(primaryItem2)
+      expect((secondaryItemRepository as ItemRepositoryInterface).deleteByUserUuid).not.toHaveBeenCalled()
+      expect(primaryItemRepository.deleteByUserUuid).toHaveBeenCalledTimes(1)
+    })
+
+    it('should log an error if deleting items from primary database fails', async () => {
+      primaryItemRepository.deleteByUserUuid = jest.fn().mockRejectedValue(new Error('error'))
+
+      const useCase = createUseCase()
+
+      const result = await useCase.execute({
+        userUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.isFailed()).toBeFalsy()
+
+      expect(logger.error).toHaveBeenCalledTimes(1)
+      expect(logger.error).toHaveBeenCalledWith(
+        'Failed to clean up primary database items for user 00000000-0000-0000-0000-000000000000: error',
+      )
+    })
+  })
+
+  describe('failed transition', () => {
+    it('should remove items from secondary database if integrity check fails', async () => {
+      const secondaryItem2WithDifferentContent = Item.create({
+        ...secondaryItem2.props,
+        content: 'different-content',
+      }).getValue()
+
+      ;(secondaryItemRepository as ItemRepositoryInterface).findByUuid = jest
+        .fn()
+        .mockResolvedValueOnce(secondaryItem1)
+        .mockResolvedValueOnce(secondaryItem2WithDifferentContent)
+
+      const useCase = createUseCase()
+
+      const result = await useCase.execute({
+        userUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.isFailed()).toBeTruthy()
+      expect(result.getError()).toEqual(
+        'Item 00000000-0000-0000-0000-000000000001 is not identical in primary and secondary database',
+      )
+
+      expect((secondaryItemRepository as ItemRepositoryInterface).deleteByUserUuid).toHaveBeenCalledTimes(1)
+      expect(primaryItemRepository.deleteByUserUuid).not.toHaveBeenCalled()
+    })
+
+    it('should remove items from secondary database if migrating items fails', async () => {
+      primaryItemRepository.findAll = jest
+        .fn()
+        .mockResolvedValueOnce([primaryItem1])
+        .mockRejectedValueOnce(new Error('error'))
+
+      const useCase = createUseCase()
+
+      const result = await useCase.execute({
+        userUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.isFailed()).toBeTruthy()
+      expect(result.getError()).toEqual('error')
+
+      expect((secondaryItemRepository as ItemRepositoryInterface).deleteByUserUuid).toHaveBeenCalledTimes(1)
+      expect(primaryItemRepository.deleteByUserUuid).not.toHaveBeenCalled()
+    })
+
+    it('should log an error if deleting items from secondary database fails upon migration failure', async () => {
+      primaryItemRepository.findAll = jest
+        .fn()
+        .mockResolvedValueOnce([primaryItem1])
+        .mockRejectedValueOnce(new Error('error'))
+      ;(secondaryItemRepository as ItemRepositoryInterface).deleteByUserUuid = jest
+        .fn()
+        .mockRejectedValue(new Error('error'))
+
+      const useCase = createUseCase()
+
+      const result = await useCase.execute({
+        userUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.isFailed()).toBeTruthy()
+
+      expect(logger.error).toHaveBeenCalledTimes(1)
+      expect(logger.error).toHaveBeenCalledWith(
+        'Failed to clean up secondary database items for user 00000000-0000-0000-0000-000000000000: error',
+      )
+    })
+
+    it('should log an error if deleting items from secondary database fails upon integrity check failure', async () => {
+      const secondaryItem2WithDifferentContent = Item.create({
+        ...secondaryItem2.props,
+        content: 'different-content',
+      }).getValue()
+
+      ;(secondaryItemRepository as ItemRepositoryInterface).findByUuid = jest
+        .fn()
+        .mockResolvedValueOnce(secondaryItem1)
+        .mockResolvedValueOnce(secondaryItem2WithDifferentContent)
+      ;(secondaryItemRepository as ItemRepositoryInterface).deleteByUserUuid = jest
+        .fn()
+        .mockRejectedValue(new Error('error'))
+
+      const useCase = createUseCase()
+
+      const result = await useCase.execute({
+        userUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.isFailed()).toBeTruthy()
+
+      expect(logger.error).toHaveBeenCalledTimes(1)
+      expect(logger.error).toHaveBeenCalledWith(
+        'Failed to clean up secondary database items for user 00000000-0000-0000-0000-000000000000: error',
+      )
+    })
+
+    it('should not perform the transition if secondary item repository is not set', async () => {
+      secondaryItemRepository = null
+
+      const useCase = createUseCase()
+
+      const result = await useCase.execute({
+        userUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.isFailed()).toBeTruthy()
+      expect(result.getError()).toEqual('Secondary item repository is not set')
+
+      expect(primaryItemRepository.countAll).not.toHaveBeenCalled()
+      expect(primaryItemRepository.findAll).not.toHaveBeenCalled()
+      expect(primaryItemRepository.deleteByUserUuid).not.toHaveBeenCalled()
+    })
+
+    it('should not perform the transition if the user uuid is invalid', async () => {
+      const useCase = createUseCase()
+
+      const result = await useCase.execute({
+        userUuid: 'invalid-uuid',
+      })
+
+      expect(result.isFailed()).toBeTruthy()
+      expect(result.getError()).toEqual('Given value is not a valid uuid: invalid-uuid')
+
+      expect(primaryItemRepository.countAll).not.toHaveBeenCalled()
+      expect(primaryItemRepository.findAll).not.toHaveBeenCalled()
+      expect(primaryItemRepository.deleteByUserUuid).not.toHaveBeenCalled()
+    })
+
+    it('should fail integrity check if the item count is not the same in both databases', async () => {
+      ;(secondaryItemRepository as ItemRepositoryInterface).countAll = jest.fn().mockResolvedValue(1)
+
+      const useCase = createUseCase()
+
+      const result = await useCase.execute({
+        userUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.isFailed()).toBeTruthy()
+      expect(result.getError()).toEqual(
+        'Total items count for user 00000000-0000-0000-0000-000000000000 in primary database (2) does not match total items count in secondary database (1)',
+      )
+
+      expect(primaryItemRepository.countAll).toHaveBeenCalledTimes(2)
+      expect(primaryItemRepository.countAll).toHaveBeenCalledWith({ userUuid: '00000000-0000-0000-0000-000000000000' })
+      expect((secondaryItemRepository as ItemRepositoryInterface).countAll).toHaveBeenCalledTimes(1)
+      expect(primaryItemRepository.deleteByUserUuid).not.toHaveBeenCalled()
+      expect((secondaryItemRepository as ItemRepositoryInterface).deleteByUserUuid).toHaveBeenCalledTimes(1)
+    })
+
+    it('should fail if one item is not found in the secondary database', async () => {
+      ;(secondaryItemRepository as ItemRepositoryInterface).findByUuid = jest
+        .fn()
+        .mockResolvedValueOnce(secondaryItem1)
+        .mockResolvedValueOnce(null)
+
+      const useCase = createUseCase()
+
+      const result = await useCase.execute({
+        userUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.isFailed()).toBeTruthy()
+      expect(result.getError()).toEqual('Item 00000000-0000-0000-0000-000000000001 not found in secondary database')
+
+      expect(primaryItemRepository.countAll).toHaveBeenCalledTimes(2)
+      expect(primaryItemRepository.countAll).toHaveBeenCalledWith({ userUuid: '00000000-0000-0000-0000-000000000000' })
+      expect((secondaryItemRepository as ItemRepositoryInterface).countAll).toHaveBeenCalledTimes(1)
+      expect(primaryItemRepository.deleteByUserUuid).not.toHaveBeenCalled()
+      expect((secondaryItemRepository as ItemRepositoryInterface).deleteByUserUuid).toHaveBeenCalledTimes(1)
+    })
+
+    it('should fail if an error is thrown during integrity check between primary and secondary database', async () => {
+      ;(secondaryItemRepository as ItemRepositoryInterface).countAll = jest.fn().mockRejectedValue(new Error('error'))
+
+      const useCase = createUseCase()
+
+      const result = await useCase.execute({
+        userUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.isFailed()).toBeTruthy()
+      expect(result.getError()).toEqual('error')
+
+      expect(primaryItemRepository.deleteByUserUuid).not.toHaveBeenCalled()
+      expect((secondaryItemRepository as ItemRepositoryInterface).deleteByUserUuid).toHaveBeenCalledTimes(1)
+    })
+  })
+})

+ 140 - 0
packages/syncing-server/src/Domain/UseCase/Transition/TransitionItemsFromPrimaryToSecondaryDatabaseForUser/TransitionItemsFromPrimaryToSecondaryDatabaseForUser.ts

@@ -0,0 +1,140 @@
+import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
+import { Logger } from 'winston'
+
+import { TransitionItemsFromPrimaryToSecondaryDatabaseForUserDTO } from './TransitionItemsFromPrimaryToSecondaryDatabaseForUserDTO'
+import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
+import { ItemQuery } from '../../../Item/ItemQuery'
+
+export class TransitionItemsFromPrimaryToSecondaryDatabaseForUser implements UseCaseInterface<void> {
+  constructor(
+    private primaryItemRepository: ItemRepositoryInterface,
+    private secondaryItemRepository: ItemRepositoryInterface | null,
+    private logger: Logger,
+  ) {}
+
+  async execute(dto: TransitionItemsFromPrimaryToSecondaryDatabaseForUserDTO): Promise<Result<void>> {
+    if (this.secondaryItemRepository === null) {
+      return Result.fail('Secondary item repository is not set')
+    }
+
+    const userUuidOrError = Uuid.create(dto.userUuid)
+    if (userUuidOrError.isFailed()) {
+      return Result.fail(userUuidOrError.getError())
+    }
+    const userUuid = userUuidOrError.getValue()
+
+    const migrationResult = await this.migrateItemsForUser(userUuid)
+    if (migrationResult.isFailed()) {
+      const cleanupResult = await this.deleteItemsForUser(userUuid, this.secondaryItemRepository)
+      if (cleanupResult.isFailed()) {
+        this.logger.error(
+          `Failed to clean up secondary database items for user ${userUuid.value}: ${cleanupResult.getError()}`,
+        )
+      }
+
+      return Result.fail(migrationResult.getError())
+    }
+
+    const integrityCheckResult = await this.checkIntegrityBetweenPrimaryAndSecondaryDatabase(userUuid)
+    if (integrityCheckResult.isFailed()) {
+      const cleanupResult = await this.deleteItemsForUser(userUuid, this.secondaryItemRepository)
+      if (cleanupResult.isFailed()) {
+        this.logger.error(
+          `Failed to clean up secondary database items for user ${userUuid.value}: ${cleanupResult.getError()}`,
+        )
+      }
+
+      return Result.fail(integrityCheckResult.getError())
+    }
+
+    const cleanupResult = await this.deleteItemsForUser(userUuid, this.primaryItemRepository)
+    if (cleanupResult.isFailed()) {
+      this.logger.error(
+        `Failed to clean up primary database items for user ${userUuid.value}: ${cleanupResult.getError()}`,
+      )
+    }
+
+    return Result.ok()
+  }
+
+  private async migrateItemsForUser(userUuid: Uuid): Promise<Result<void>> {
+    try {
+      const totalItemsCountForUser = await this.primaryItemRepository.countAll({ userUuid: userUuid.value })
+      const pageSize = 1
+      const totalPages = totalItemsCountForUser
+      let currentPage = 1
+      for (currentPage; currentPage <= totalPages; currentPage++) {
+        const query: ItemQuery = {
+          userUuid: userUuid.value,
+          offset: currentPage - 1,
+          limit: pageSize,
+        }
+
+        const items = await this.primaryItemRepository.findAll(query)
+
+        for (const item of items) {
+          await (this.secondaryItemRepository as ItemRepositoryInterface).save(item)
+        }
+      }
+
+      return Result.ok()
+    } catch (error) {
+      return Result.fail((error as Error).message)
+    }
+  }
+
+  private async deleteItemsForUser(userUuid: Uuid, itemRepository: ItemRepositoryInterface): Promise<Result<void>> {
+    try {
+      await itemRepository.deleteByUserUuid(userUuid.value)
+
+      return Result.ok()
+    } catch (error) {
+      return Result.fail((error as Error).message)
+    }
+  }
+
+  private async checkIntegrityBetweenPrimaryAndSecondaryDatabase(userUuid: Uuid): Promise<Result<boolean>> {
+    try {
+      const totalItemsCountForUserInPrimary = await this.primaryItemRepository.countAll({ userUuid: userUuid.value })
+      const totalItemsCountForUserInSecondary = await (
+        this.secondaryItemRepository as ItemRepositoryInterface
+      ).countAll({
+        userUuid: userUuid.value,
+      })
+
+      if (totalItemsCountForUserInPrimary !== totalItemsCountForUserInSecondary) {
+        return Result.fail(
+          `Total items count for user ${userUuid.value} in primary database (${totalItemsCountForUserInPrimary}) does not match total items count in secondary database (${totalItemsCountForUserInSecondary})`,
+        )
+      }
+
+      const pageSize = 1
+      const totalPages = totalItemsCountForUserInPrimary
+      let currentPage = 1
+      for (currentPage; currentPage <= totalPages; currentPage++) {
+        const query: ItemQuery = {
+          userUuid: userUuid.value,
+          offset: currentPage - 1,
+          limit: pageSize,
+        }
+
+        const items = await this.primaryItemRepository.findAll(query)
+
+        for (const item of items) {
+          const itemInSecondary = await (this.secondaryItemRepository as ItemRepositoryInterface).findByUuid(item.uuid)
+          if (!itemInSecondary) {
+            return Result.fail(`Item ${item.uuid.value} not found in secondary database`)
+          }
+
+          if (!item.isIdenticalTo(itemInSecondary)) {
+            return Result.fail(`Item ${item.uuid.value} is not identical in primary and secondary database`)
+          }
+        }
+      }
+
+      return Result.ok()
+    } catch (error) {
+      return Result.fail((error as Error).message)
+    }
+  }
+}

+ 3 - 0
packages/syncing-server/src/Domain/UseCase/Transition/TransitionItemsFromPrimaryToSecondaryDatabaseForUser/TransitionItemsFromPrimaryToSecondaryDatabaseForUserDTO.ts

@@ -0,0 +1,3 @@
+export interface TransitionItemsFromPrimaryToSecondaryDatabaseForUserDTO {
+  userUuid: string
+}