浏览代码

feat(syncing-server): associating existing items with key systems and shared vaults (#661)

* feat(syncing-server): associating existing items with key systems and shared vaults

* fix(syncing-server): find item query

* feat(syncing-server): add persistence of shared vaults with users and invites
Karol Sójko 1 年之前
父节点
当前提交
3b804e2321
共有 19 个文件被更改,包括 488 次插入20 次删除
  1. 29 0
      packages/syncing-server/migrations/mysql/1689677728282-add-shared-vaults-with-users-and-invites.ts
  2. 41 0
      packages/syncing-server/migrations/sqlite/1689677867175-add-shared-vaults-with-users-and-invites.ts
  3. 12 1
      packages/syncing-server/src/Bootstrap/DataSource.ts
  4. 10 1
      packages/syncing-server/src/Domain/Handler/ItemRevisionCreationRequestedEventHandler.spec.ts
  5. 8 1
      packages/syncing-server/src/Domain/Handler/ItemRevisionCreationRequestedEventHandler.ts
  6. 4 2
      packages/syncing-server/src/Domain/Item/ItemRepositoryInterface.ts
  7. 3 0
      packages/syncing-server/src/Domain/KeySystem/KeySystemAssociationRepositoryInterface.ts
  8. 3 0
      packages/syncing-server/src/Domain/SharedVault/SharedVaultAssociationRepositoryInterface.ts
  9. 28 4
      packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItems.spec.ts
  10. 14 2
      packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItems.ts
  11. 205 0
      packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem.spec.ts
  12. 74 0
      packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem.ts
  13. 1 0
      packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItemDTO.ts
  14. 16 4
      packages/syncing-server/src/Infra/TypeORM/TypeORMItemRepository.ts
  15. 16 1
      packages/syncing-server/src/Infra/TypeORM/TypeORMKeySystemAssociationRepository.ts
  16. 1 1
      packages/syncing-server/src/Infra/TypeORM/TypeORMSharedVault.ts
  17. 16 1
      packages/syncing-server/src/Infra/TypeORM/TypeORMSharedVaultAssociationRepository.ts
  18. 4 1
      packages/syncing-server/src/Infra/TypeORM/TypeORMSharedVaultInvite.ts
  19. 3 1
      packages/syncing-server/src/Infra/TypeORM/TypeORMSharedVaultUser.ts

+ 29 - 0
packages/syncing-server/migrations/mysql/1689677728282-add-shared-vaults-with-users-and-invites.ts

@@ -0,0 +1,29 @@
+import { MigrationInterface, QueryRunner } from 'typeorm'
+
+export class AddSharedVaultsWithUsersAndInvites1689677728282 implements MigrationInterface {
+  name = 'AddSharedVaultsWithUsersAndInvites1689677728282'
+
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(
+      'CREATE TABLE `shared_vaults` (`uuid` varchar(36) NOT NULL, `user_uuid` varchar(36) NOT NULL, `file_upload_bytes_used` int NOT NULL, `file_upload_bytes_limit` int NOT NULL, `created_at_timestamp` bigint NOT NULL, `updated_at_timestamp` bigint NOT NULL, INDEX `user_uuid_on_shared_vaults` (`user_uuid`), PRIMARY KEY (`uuid`)) ENGINE=InnoDB',
+    )
+    await queryRunner.query(
+      'CREATE TABLE `shared_vault_users` (`uuid` varchar(36) NOT NULL, `shared_vault_uuid` varchar(36) NOT NULL, `user_uuid` varchar(36) NOT NULL, `permission` varchar(24) NOT NULL, `created_at_timestamp` bigint NOT NULL, `updated_at_timestamp` bigint NOT NULL, INDEX `shared_vault_uuid_on_shared_vault_users` (`shared_vault_uuid`), INDEX `user_uuid_on_shared_vault_users` (`user_uuid`), PRIMARY KEY (`uuid`)) ENGINE=InnoDB',
+    )
+    await queryRunner.query(
+      'CREATE TABLE `shared_vault_invites` (`uuid` varchar(36) NOT NULL, `shared_vault_uuid` varchar(36) NOT NULL, `user_uuid` varchar(36) NOT NULL, `sender_uuid` varchar(36) NOT NULL, `encrypted_message` text NOT NULL, `permission` varchar(24) NOT NULL, `created_at_timestamp` bigint NOT NULL, `updated_at_timestamp` bigint NOT NULL, INDEX `shared_vault_uuid_on_shared_vault_invites` (`shared_vault_uuid`), INDEX `user_uuid_on_shared_vault_invites` (`user_uuid`), INDEX `sender_uuid_on_shared_vault_invites` (`sender_uuid`), PRIMARY KEY (`uuid`)) ENGINE=InnoDB',
+    )
+  }
+
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query('DROP INDEX `sender_uuid_on_shared_vault_invites` ON `shared_vault_invites`')
+    await queryRunner.query('DROP INDEX `user_uuid_on_shared_vault_invites` ON `shared_vault_invites`')
+    await queryRunner.query('DROP INDEX `shared_vault_uuid_on_shared_vault_invites` ON `shared_vault_invites`')
+    await queryRunner.query('DROP TABLE `shared_vault_invites`')
+    await queryRunner.query('DROP INDEX `user_uuid_on_shared_vault_users` ON `shared_vault_users`')
+    await queryRunner.query('DROP INDEX `shared_vault_uuid_on_shared_vault_users` ON `shared_vault_users`')
+    await queryRunner.query('DROP TABLE `shared_vault_users`')
+    await queryRunner.query('DROP INDEX `user_uuid_on_shared_vaults` ON `shared_vaults`')
+    await queryRunner.query('DROP TABLE `shared_vaults`')
+  }
+}

+ 41 - 0
packages/syncing-server/migrations/sqlite/1689677867175-add-shared-vaults-with-users-and-invites.ts

@@ -0,0 +1,41 @@
+import { MigrationInterface, QueryRunner } from 'typeorm'
+
+export class AddSharedVaultsWithUsersAndInvites1689677867175 implements MigrationInterface {
+  name = 'AddSharedVaultsWithUsersAndInvites1689677867175'
+
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(
+      'CREATE TABLE "shared_vaults" ("uuid" varchar PRIMARY KEY NOT NULL, "user_uuid" varchar(36) NOT NULL, "file_upload_bytes_used" integer NOT NULL, "file_upload_bytes_limit" integer NOT NULL, "created_at_timestamp" bigint NOT NULL, "updated_at_timestamp" bigint NOT NULL)',
+    )
+    await queryRunner.query('CREATE INDEX "user_uuid_on_shared_vaults" ON "shared_vaults" ("user_uuid") ')
+    await queryRunner.query(
+      'CREATE TABLE "shared_vault_users" ("uuid" varchar PRIMARY KEY NOT NULL, "shared_vault_uuid" varchar(36) NOT NULL, "user_uuid" varchar(36) NOT NULL, "permission" varchar(24) NOT NULL, "created_at_timestamp" bigint NOT NULL, "updated_at_timestamp" bigint NOT NULL)',
+    )
+    await queryRunner.query(
+      'CREATE INDEX "shared_vault_uuid_on_shared_vault_users" ON "shared_vault_users" ("shared_vault_uuid") ',
+    )
+    await queryRunner.query('CREATE INDEX "user_uuid_on_shared_vault_users" ON "shared_vault_users" ("user_uuid") ')
+    await queryRunner.query(
+      'CREATE TABLE "shared_vault_invites" ("uuid" varchar PRIMARY KEY NOT NULL, "shared_vault_uuid" varchar(36) NOT NULL, "user_uuid" varchar(36) NOT NULL, "sender_uuid" varchar(36) NOT NULL, "encrypted_message" text NOT NULL, "permission" varchar(24) NOT NULL, "created_at_timestamp" bigint NOT NULL, "updated_at_timestamp" bigint NOT NULL)',
+    )
+    await queryRunner.query(
+      'CREATE INDEX "shared_vault_uuid_on_shared_vault_invites" ON "shared_vault_invites" ("shared_vault_uuid") ',
+    )
+    await queryRunner.query('CREATE INDEX "user_uuid_on_shared_vault_invites" ON "shared_vault_invites" ("user_uuid") ')
+    await queryRunner.query(
+      'CREATE INDEX "sender_uuid_on_shared_vault_invites" ON "shared_vault_invites" ("sender_uuid") ',
+    )
+  }
+
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query('DROP INDEX "sender_uuid_on_shared_vault_invites"')
+    await queryRunner.query('DROP INDEX "user_uuid_on_shared_vault_invites"')
+    await queryRunner.query('DROP INDEX "shared_vault_uuid_on_shared_vault_invites"')
+    await queryRunner.query('DROP TABLE "shared_vault_invites"')
+    await queryRunner.query('DROP INDEX "user_uuid_on_shared_vault_users"')
+    await queryRunner.query('DROP INDEX "shared_vault_uuid_on_shared_vault_users"')
+    await queryRunner.query('DROP TABLE "shared_vault_users"')
+    await queryRunner.query('DROP INDEX "user_uuid_on_shared_vaults"')
+    await queryRunner.query('DROP TABLE "shared_vaults"')
+  }
+}

+ 12 - 1
packages/syncing-server/src/Bootstrap/DataSource.ts

@@ -6,6 +6,9 @@ import { TypeORMItem } from '../Infra/TypeORM/TypeORMItem'
 import { TypeORMNotification } from '../Infra/TypeORM/TypeORMNotification'
 import { TypeORMSharedVaultAssociation } from '../Infra/TypeORM/TypeORMSharedVaultAssociation'
 import { TypeORMKeySystemAssociation } from '../Infra/TypeORM/TypeORMKeySystemAssociation'
+import { TypeORMSharedVault } from '../Infra/TypeORM/TypeORMSharedVault'
+import { TypeORMSharedVaultUser } from '../Infra/TypeORM/TypeORMSharedVaultUser'
+import { TypeORMSharedVaultInvite } from '../Infra/TypeORM/TypeORMSharedVaultInvite'
 
 export class AppDataSource {
   private _dataSource: DataSource | undefined
@@ -35,7 +38,15 @@ export class AppDataSource {
 
     const commonDataSourceOptions = {
       maxQueryExecutionTime,
-      entities: [TypeORMItem, TypeORMNotification, TypeORMSharedVaultAssociation, TypeORMKeySystemAssociation],
+      entities: [
+        TypeORMItem,
+        TypeORMNotification,
+        TypeORMSharedVaultAssociation,
+        TypeORMKeySystemAssociation,
+        TypeORMSharedVault,
+        TypeORMSharedVaultUser,
+        TypeORMSharedVaultInvite,
+      ],
       migrations: [`${__dirname}/../../migrations/${isConfiguredForMySQL ? 'mysql' : 'sqlite'}/*.js`],
       migrationsRun: true,
       logging: <LoggerOptions>this.env.get('DB_DEBUG_LEVEL', true) ?? 'info',

+ 10 - 1
packages/syncing-server/src/Domain/Handler/ItemRevisionCreationRequestedEventHandler.spec.ts

@@ -53,7 +53,7 @@ describe('ItemRevisionCreationRequestedEventHandler', () => {
     event = {} as jest.Mocked<ItemRevisionCreationRequestedEvent>
     event.createdAt = new Date(1)
     event.payload = {
-      itemUuid: '2-3-4',
+      itemUuid: '00000000-0000-0000-0000-000000000000',
     }
     event.meta = {
       correlation: {
@@ -96,4 +96,13 @@ describe('ItemRevisionCreationRequestedEventHandler', () => {
     expect(domainEventPublisher.publish).not.toHaveBeenCalled()
     expect(domainEventFactory.createItemDumpedEvent).not.toHaveBeenCalled()
   })
+
+  it('should not create a revision if the item uuid is invalid', async () => {
+    event.payload.itemUuid = 'invalid-uuid'
+
+    await createHandler().handle(event)
+
+    expect(domainEventPublisher.publish).not.toHaveBeenCalled()
+    expect(domainEventFactory.createItemDumpedEvent).not.toHaveBeenCalled()
+  })
 })

+ 8 - 1
packages/syncing-server/src/Domain/Handler/ItemRevisionCreationRequestedEventHandler.ts

@@ -3,6 +3,7 @@ import {
   DomainEventHandlerInterface,
   DomainEventPublisherInterface,
 } from '@standardnotes/domain-events'
+import { Uuid } from '@standardnotes/domain-core'
 
 import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface'
 import { ItemBackupServiceInterface } from '../Item/ItemBackupServiceInterface'
@@ -17,7 +18,13 @@ export class ItemRevisionCreationRequestedEventHandler implements DomainEventHan
   ) {}
 
   async handle(event: ItemRevisionCreationRequestedEvent): Promise<void> {
-    const item = await this.itemRepository.findByUuid(event.payload.itemUuid)
+    const itemUuidOrError = Uuid.create(event.payload.itemUuid)
+    if (itemUuidOrError.isFailed()) {
+      return
+    }
+    const itemUuid = itemUuidOrError.getValue()
+
+    const item = await this.itemRepository.findByUuid(itemUuid)
     if (item === null) {
       return
     }

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

@@ -1,6 +1,8 @@
+import { Uuid } from '@standardnotes/domain-core'
+import { ReadStream } from 'fs'
+
 import { Item } from './Item'
 import { ItemQuery } from './ItemQuery'
-import { ReadStream } from 'fs'
 import { ExtendedIntegrityPayload } from './ExtendedIntegrityPayload'
 
 export interface ItemRepositoryInterface {
@@ -15,7 +17,7 @@ export interface ItemRepositoryInterface {
   findDatesForComputingIntegrityHash(userUuid: string): Promise<Array<{ updated_at_timestamp: number }>>
   findItemsForComputingIntegrityPayloads(userUuid: string): Promise<ExtendedIntegrityPayload[]>
   findByUuidAndUserUuid(uuid: string, userUuid: string): Promise<Item | null>
-  findByUuid(uuid: string): Promise<Item | null>
+  findByUuid(uuid: Uuid): Promise<Item | null>
   remove(item: Item): Promise<void>
   save(item: Item): Promise<void>
   markItemsAsDeleted(itemUuids: Array<string>, updatedAtTimestamp: number): Promise<void>

+ 3 - 0
packages/syncing-server/src/Domain/KeySystem/KeySystemAssociationRepositoryInterface.ts

@@ -1,6 +1,9 @@
+import { Uuid } from '@standardnotes/domain-core'
+
 import { KeySystemAssociation } from './KeySystemAssociation'
 
 export interface KeySystemAssociationRepositoryInterface {
   save(keySystem: KeySystemAssociation): Promise<void>
   remove(keySystem: KeySystemAssociation): Promise<void>
+  findByItemUuid(itemUuid: Uuid): Promise<KeySystemAssociation | null>
 }

+ 3 - 0
packages/syncing-server/src/Domain/SharedVault/SharedVaultAssociationRepositoryInterface.ts

@@ -1,6 +1,9 @@
+import { Uuid } from '@standardnotes/domain-core'
+
 import { SharedVaultAssociation } from './SharedVaultAssociation'
 
 export interface SharedVaultAssociationRepositoryInterface {
   save(sharedVaultAssociation: SharedVaultAssociation): Promise<void>
   remove(sharedVaultAssociation: SharedVaultAssociation): Promise<void>
+  findByItemUuid(itemUuid: Uuid): Promise<SharedVaultAssociation | null>
 }

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

@@ -56,7 +56,7 @@ describe('SaveItems', () => {
     logger.error = jest.fn()
 
     itemHash1 = ItemHash.create({
-      uuid: 'item-uuid',
+      uuid: '00000000-0000-0000-0000-000000000000',
       user_uuid: 'user-uuid',
       content: 'content',
       content_type: ContentType.TYPES.Note,
@@ -197,7 +197,7 @@ describe('SaveItems', () => {
 
     const result = await useCase.execute({
       itemHashes: [itemHash1],
-      userUuid: 'user-uuid',
+      userUuid: '00000000-0000-0000-0000-000000000000',
       apiVersion: '1',
       readOnlyAccess: false,
       sessionUuid: 'session-uuid',
@@ -208,6 +208,7 @@ describe('SaveItems', () => {
       itemHash: itemHash1,
       existingItem: savedItem,
       sessionUuid: 'session-uuid',
+      performingUserUuid: '00000000-0000-0000-0000-000000000000',
     })
   })
 
@@ -234,6 +235,29 @@ describe('SaveItems', () => {
     ])
   })
 
+  it('should mark items as conflict if the item uuid is invalid', async () => {
+    const useCase = createUseCase()
+
+    itemRepository.findByUuid = jest.fn().mockResolvedValue(savedItem)
+    updateExistingItem.execute = jest.fn().mockResolvedValue(Result.fail('error'))
+
+    const result = await useCase.execute({
+      itemHashes: [ItemHash.create({ ...itemHash1.props, uuid: 'invalid-uuid' }).getValue()],
+      userUuid: 'user-uuid',
+      apiVersion: '1',
+      readOnlyAccess: false,
+      sessionUuid: 'session-uuid',
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+    expect(result.getValue().conflicts).toEqual([
+      {
+        unsavedItem: ItemHash.create({ ...itemHash1.props, uuid: 'invalid-uuid' }).getValue(),
+        type: 'uuid_conflict',
+      },
+    ])
+  })
+
   it('should calculate the sync token based on existing and new items saved', async () => {
     const useCase = createUseCase()
 
@@ -260,8 +284,8 @@ describe('SaveItems', () => {
     const result = await useCase.execute({
       itemHashes: [
         itemHash1,
-        ItemHash.create({ ...itemHash1.props, uuid: 'item-uuid-2' }).getValue(),
-        ItemHash.create({ ...itemHash1.props, uuid: 'item-uuid-2' }).getValue(),
+        ItemHash.create({ ...itemHash1.props, uuid: '00000000-0000-0000-0000-000000000002' }).getValue(),
+        ItemHash.create({ ...itemHash1.props, uuid: '00000000-0000-0000-0000-000000000003' }).getValue(),
       ],
       userUuid: 'user-uuid',
       apiVersion: '2',

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

@@ -1,4 +1,4 @@
-import { Result, UseCaseInterface } from '@standardnotes/domain-core'
+import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
 
 import { SaveItemsResult } from './SaveItemsResult'
 import { SaveItemsDTO } from './SaveItemsDTO'
@@ -40,7 +40,18 @@ export class SaveItems implements UseCaseInterface<SaveItemsResult> {
         continue
       }
 
-      const existingItem = await this.itemRepository.findByUuid(itemHash.props.uuid)
+      const itemUuidOrError = Uuid.create(itemHash.props.uuid)
+      if (itemUuidOrError.isFailed()) {
+        conflicts.push({
+          unsavedItem: itemHash,
+          type: ConflictType.UuidConflict,
+        })
+
+        continue
+      }
+      const itemUuid = itemUuidOrError.getValue()
+
+      const existingItem = await this.itemRepository.findByUuid(itemUuid)
       const processingResult = await this.itemSaveValidator.validate({
         userUuid: dto.userUuid,
         apiVersion: dto.apiVersion,
@@ -63,6 +74,7 @@ export class SaveItems implements UseCaseInterface<SaveItemsResult> {
           existingItem,
           itemHash,
           sessionUuid: dto.sessionUuid,
+          performingUserUuid: dto.userUuid,
         })
         if (udpatedItemOrError.isFailed()) {
           this.logger.error(

+ 205 - 0
packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem.spec.ts

@@ -6,6 +6,8 @@ import { ItemHash } from '../../../Item/ItemHash'
 import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
 import { UpdateExistingItem } from './UpdateExistingItem'
 import { Uuid, ContentType, Dates, Timestamps, UniqueEntityId, Result } from '@standardnotes/domain-core'
+import { SharedVaultAssociation } from '../../../SharedVault/SharedVaultAssociation'
+import { KeySystemAssociation } from '../../../KeySystem/KeySystemAssociation'
 
 describe('UpdateExistingItem', () => {
   let itemRepository: ItemRepositoryInterface
@@ -87,6 +89,7 @@ describe('UpdateExistingItem', () => {
       existingItem: item1,
       itemHash: itemHash1,
       sessionUuid: '00000000-0000-0000-0000-000000000000',
+      performingUserUuid: '00000000-0000-0000-0000-000000000000',
     })
 
     expect(result.isFailed()).toBeFalsy()
@@ -100,6 +103,7 @@ describe('UpdateExistingItem', () => {
       existingItem: item1,
       itemHash: itemHash1,
       sessionUuid: 'invalid-uuid',
+      performingUserUuid: '00000000-0000-0000-0000-000000000000',
     })
 
     expect(result.isFailed()).toBeTruthy()
@@ -115,6 +119,7 @@ describe('UpdateExistingItem', () => {
         content_type: 'invalid',
       }).getValue(),
       sessionUuid: '00000000-0000-0000-0000-000000000000',
+      performingUserUuid: '00000000-0000-0000-0000-000000000000',
     })
 
     expect(result.isFailed()).toBeTruthy()
@@ -130,6 +135,7 @@ describe('UpdateExistingItem', () => {
         deleted: true,
       }).getValue(),
       sessionUuid: '00000000-0000-0000-0000-000000000000',
+      performingUserUuid: '00000000-0000-0000-0000-000000000000',
     })
 
     expect(result.isFailed()).toBeFalsy()
@@ -152,6 +158,7 @@ describe('UpdateExistingItem', () => {
         duplicate_of: '00000000-0000-0000-0000-000000000001',
       }).getValue(),
       sessionUuid: '00000000-0000-0000-0000-000000000000',
+      performingUserUuid: '00000000-0000-0000-0000-000000000000',
     })
 
     expect(result.isFailed()).toBeFalsy()
@@ -169,6 +176,7 @@ describe('UpdateExistingItem', () => {
         duplicate_of: 'invalid-uuid',
       }).getValue(),
       sessionUuid: '00000000-0000-0000-0000-000000000000',
+      performingUserUuid: '00000000-0000-0000-0000-000000000000',
     })
 
     expect(result.isFailed()).toBeTruthy()
@@ -185,6 +193,7 @@ describe('UpdateExistingItem', () => {
         created_at_timestamp: 123,
       }).getValue(),
       sessionUuid: '00000000-0000-0000-0000-000000000000',
+      performingUserUuid: '00000000-0000-0000-0000-000000000000',
     })
 
     expect(result.isFailed()).toBeFalsy()
@@ -202,6 +211,7 @@ describe('UpdateExistingItem', () => {
         created_at_timestamp: undefined,
       }).getValue(),
       sessionUuid: '00000000-0000-0000-0000-000000000000',
+      performingUserUuid: '00000000-0000-0000-0000-000000000000',
     })
 
     expect(result.isFailed()).toBeTruthy()
@@ -223,6 +233,7 @@ describe('UpdateExistingItem', () => {
         updated_at_timestamp: 123,
       }).getValue(),
       sessionUuid: '00000000-0000-0000-0000-000000000000',
+      performingUserUuid: '00000000-0000-0000-0000-000000000000',
     })
 
     expect(result.isFailed()).toBeTruthy()
@@ -246,9 +257,203 @@ describe('UpdateExistingItem', () => {
         updated_at_timestamp: 123,
       }).getValue(),
       sessionUuid: '00000000-0000-0000-0000-000000000000',
+      performingUserUuid: '00000000-0000-0000-0000-000000000000',
     })
 
     expect(result.isFailed()).toBeTruthy()
     mock.mockRestore()
   })
+
+  it('should return error if performing user uuid is invalid', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      existingItem: item1,
+      itemHash: itemHash1,
+      sessionUuid: '00000000-0000-0000-0000-000000000000',
+      performingUserUuid: 'invalid-uuid',
+    })
+    expect(result.isFailed()).toBeTruthy()
+  })
+
+  describe('when item is associated to a shared vault', () => {
+    it('should add a shared vault association if item hash represents a shared vault item and the existing item is not already associated to the shared vault', async () => {
+      const useCase = createUseCase()
+
+      const itemHash = ItemHash.create({
+        ...itemHash1.props,
+        shared_vault_uuid: '00000000-0000-0000-0000-000000000000',
+      }).getValue()
+
+      const result = await useCase.execute({
+        existingItem: item1,
+        itemHash,
+        sessionUuid: '00000000-0000-0000-0000-000000000000',
+        performingUserUuid: '00000000-0000-0000-0000-000000000000',
+      })
+      expect(result.isFailed()).toBeFalsy()
+      expect(item1.props.sharedVaultAssociation).not.toBeUndefined()
+      expect(item1.props.sharedVaultAssociation?.props.sharedVaultUuid.value).toBe(
+        '00000000-0000-0000-0000-000000000000',
+      )
+    })
+
+    it('should not add a shared vault association if item hash represents a shared vault item and the existing item is already associated to the shared vault', async () => {
+      const useCase = createUseCase()
+
+      const itemHash = ItemHash.create({
+        ...itemHash1.props,
+        shared_vault_uuid: '00000000-0000-0000-0000-000000000000',
+      }).getValue()
+
+      item1.props.sharedVaultAssociation = SharedVaultAssociation.create({
+        itemUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+        sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+        lastEditedBy: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+        timestamps: Timestamps.create(123, 123).getValue(),
+      }).getValue()
+      const idBefore = item1.props.sharedVaultAssociation?.id.toString()
+
+      const result = await useCase.execute({
+        existingItem: item1,
+        itemHash,
+        sessionUuid: '00000000-0000-0000-0000-000000000000',
+        performingUserUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.isFailed()).toBeFalsy()
+
+      expect(item1.props.sharedVaultAssociation).not.toBeUndefined()
+      expect(item1.props.sharedVaultAssociation.id.toString()).toEqual(idBefore)
+    })
+
+    it('should return error if shared vault uuid is invalid', async () => {
+      const useCase = createUseCase()
+
+      const itemHash = ItemHash.create({
+        ...itemHash1.props,
+        shared_vault_uuid: 'invalid-uuid',
+      }).getValue()
+
+      const result = await useCase.execute({
+        existingItem: item1,
+        itemHash,
+        sessionUuid: '00000000-0000-0000-0000-000000000000',
+        performingUserUuid: '00000000-0000-0000-0000-000000000000',
+      })
+      expect(result.isFailed()).toBeTruthy()
+    })
+
+    it('should return error if shared vault association could not be created', async () => {
+      const useCase = createUseCase()
+
+      const itemHash = ItemHash.create({
+        ...itemHash1.props,
+        shared_vault_uuid: '00000000-0000-0000-0000-000000000000',
+      }).getValue()
+
+      const mock = jest.spyOn(SharedVaultAssociation, 'create')
+      mock.mockImplementation(() => {
+        return Result.fail('Oops')
+      })
+
+      const result = await useCase.execute({
+        existingItem: item1,
+        itemHash,
+        sessionUuid: '00000000-0000-0000-0000-000000000000',
+        performingUserUuid: '00000000-0000-0000-0000-000000000000',
+      })
+      expect(result.isFailed()).toBeTruthy()
+      mock.mockRestore()
+    })
+  })
+
+  describe('when item is associated to a key system', () => {
+    it('should add a key system association if item hash has a dedicated key system and the existing item is not already associated to the key system', async () => {
+      const useCase = createUseCase()
+
+      const itemHash = ItemHash.create({
+        ...itemHash1.props,
+        key_system_identifier: '00000000-0000-0000-0000-000000000000',
+      }).getValue()
+
+      const result = await useCase.execute({
+        existingItem: item1,
+        itemHash,
+        sessionUuid: '00000000-0000-0000-0000-000000000000',
+        performingUserUuid: '00000000-0000-0000-0000-000000000000',
+      })
+      expect(result.isFailed()).toBeFalsy()
+      expect(item1.props.keySystemAssociation).not.toBeUndefined()
+      expect(item1.props.keySystemAssociation?.props.keySystemUuid.value).toBe('00000000-0000-0000-0000-000000000000')
+    })
+
+    it('should not add a key system association if item hash has a dedicated key system and the existing item is already associated to the key system', async () => {
+      const useCase = createUseCase()
+
+      const itemHash = ItemHash.create({
+        ...itemHash1.props,
+        key_system_identifier: '00000000-0000-0000-0000-000000000000',
+      }).getValue()
+
+      item1.props.keySystemAssociation = KeySystemAssociation.create({
+        itemUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+        keySystemUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+        timestamps: Timestamps.create(123, 123).getValue(),
+      }).getValue()
+      const idBefore = item1.props.keySystemAssociation?.id.toString()
+
+      const result = await useCase.execute({
+        existingItem: item1,
+        itemHash,
+        sessionUuid: '00000000-0000-0000-0000-000000000000',
+        performingUserUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.isFailed()).toBeFalsy()
+
+      expect(item1.props.keySystemAssociation).not.toBeUndefined()
+      expect(item1.props.keySystemAssociation.id.toString()).toEqual(idBefore)
+    })
+
+    it('should return error if key system uuid is invalid', async () => {
+      const useCase = createUseCase()
+
+      const itemHash = ItemHash.create({
+        ...itemHash1.props,
+        key_system_identifier: 'invalid-uuid',
+      }).getValue()
+
+      const result = await useCase.execute({
+        existingItem: item1,
+        itemHash,
+        sessionUuid: '00000000-0000-0000-0000-000000000000',
+        performingUserUuid: '00000000-0000-0000-0000-000000000000',
+      })
+      expect(result.isFailed()).toBeTruthy()
+    })
+
+    it('should return error if key system association could not be created', async () => {
+      const useCase = createUseCase()
+
+      const itemHash = ItemHash.create({
+        ...itemHash1.props,
+        key_system_identifier: '00000000-0000-0000-0000-000000000000',
+      }).getValue()
+
+      const mock = jest.spyOn(KeySystemAssociation, 'create')
+      mock.mockImplementation(() => {
+        return Result.fail('Oops')
+      })
+
+      const result = await useCase.execute({
+        existingItem: item1,
+        itemHash,
+        sessionUuid: '00000000-0000-0000-0000-000000000000',
+        performingUserUuid: '00000000-0000-0000-0000-000000000000',
+      })
+      expect(result.isFailed()).toBeTruthy()
+      mock.mockRestore()
+    })
+  })
 })

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

@@ -6,6 +6,9 @@ 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'
+import { ItemHash } from '../../../Item/ItemHash'
 
 export class UpdateExistingItem implements UseCaseInterface<Item> {
   constructor(
@@ -27,6 +30,12 @@ export class UpdateExistingItem implements UseCaseInterface<Item> {
     }
     dto.existingItem.props.updatedWithSession = sessionUuid
 
+    const userUuidOrError = Uuid.create(dto.performingUserUuid)
+    if (userUuidOrError.isFailed()) {
+      return Result.fail(userUuidOrError.getError())
+    }
+    const userUuid = userUuidOrError.getValue()
+
     if (dto.itemHash.props.content) {
       dto.existingItem.props.content = dto.itemHash.props.content
     }
@@ -96,6 +105,57 @@ export class UpdateExistingItem implements UseCaseInterface<Item> {
 
     dto.existingItem.props.contentSize = Buffer.byteLength(JSON.stringify(dto.existingItem))
 
+    if (
+      dto.itemHash.representsASharedVaultItem() &&
+      !this.itemIsAlreadyAssociatedWithTheSharedVault(dto.existingItem, dto.itemHash)
+    ) {
+      const sharedVaultUuidOrError = Uuid.create(dto.itemHash.props.shared_vault_uuid as string)
+      if (sharedVaultUuidOrError.isFailed()) {
+        return Result.fail(sharedVaultUuidOrError.getError())
+      }
+      const sharedVaultUuid = sharedVaultUuidOrError.getValue()
+
+      const sharedVaultAssociationOrError = SharedVaultAssociation.create({
+        lastEditedBy: userUuid,
+        sharedVaultUuid,
+        timestamps: Timestamps.create(
+          this.timer.getTimestampInMicroseconds(),
+          this.timer.getTimestampInMicroseconds(),
+        ).getValue(),
+        itemUuid: Uuid.create(dto.existingItem.id.toString()).getValue(),
+      })
+      if (sharedVaultAssociationOrError.isFailed()) {
+        return Result.fail(sharedVaultAssociationOrError.getError())
+      }
+
+      dto.existingItem.props.sharedVaultAssociation = sharedVaultAssociationOrError.getValue()
+    }
+
+    if (
+      dto.itemHash.hasDedicatedKeySystemAssociation() &&
+      !this.itemIsAlreadyAssociatedWithTheKeySystem(dto.existingItem, dto.itemHash)
+    ) {
+      const keySystemUuidOrError = Uuid.create(dto.itemHash.props.key_system_identifier as string)
+      if (keySystemUuidOrError.isFailed()) {
+        return Result.fail(keySystemUuidOrError.getError())
+      }
+      const keySystemUuid = keySystemUuidOrError.getValue()
+
+      const keySystemAssociationOrError = KeySystemAssociation.create({
+        itemUuid: Uuid.create(dto.existingItem.id.toString()).getValue(),
+        timestamps: Timestamps.create(
+          this.timer.getTimestampInMicroseconds(),
+          this.timer.getTimestampInMicroseconds(),
+        ).getValue(),
+        keySystemUuid,
+      })
+      if (keySystemAssociationOrError.isFailed()) {
+        return Result.fail(keySystemAssociationOrError.getError())
+      }
+
+      dto.existingItem.props.keySystemAssociation = keySystemAssociationOrError.getValue()
+    }
+
     if (dto.itemHash.props.deleted === true) {
       dto.existingItem.props.deleted = true
       dto.existingItem.props.content = null
@@ -132,4 +192,18 @@ export class UpdateExistingItem implements UseCaseInterface<Item> {
 
     return Result.ok(dto.existingItem)
   }
+
+  private itemIsAlreadyAssociatedWithTheSharedVault(item: Item, itemHash: ItemHash): boolean {
+    return (
+      item.props.sharedVaultAssociation !== undefined &&
+      item.props.sharedVaultAssociation.props.sharedVaultUuid.value === itemHash.props.shared_vault_uuid
+    )
+  }
+
+  private itemIsAlreadyAssociatedWithTheKeySystem(item: Item, itemHash: ItemHash): boolean {
+    return (
+      item.props.keySystemAssociation !== undefined &&
+      item.props.keySystemAssociation.props.keySystemUuid.value === itemHash.props.key_system_identifier
+    )
+  }
 }

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

@@ -5,4 +5,5 @@ export interface UpdateExistingItemDTO {
   existingItem: Item
   itemHash: ItemHash
   sessionUuid: string | null
+  performingUserUuid: string
 }

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

@@ -1,6 +1,6 @@
 import { ReadStream } from 'fs'
 import { Repository, SelectQueryBuilder } from 'typeorm'
-import { MapperInterface } from '@standardnotes/domain-core'
+import { MapperInterface, Uuid } from '@standardnotes/domain-core'
 
 import { Item } from '../../Domain/Item/Item'
 import { ItemQuery } from '../../Domain/Item/ItemQuery'
@@ -78,11 +78,11 @@ export class TypeORMItemRepository implements ItemRepositoryInterface {
       .execute()
   }
 
-  async findByUuid(uuid: string): Promise<Item | null> {
+  async findByUuid(uuid: Uuid): Promise<Item | null> {
     const persistence = await this.ormRepository
       .createQueryBuilder('item')
       .where('item.uuid = :uuid', {
-        uuid,
+        uuid: uuid.value,
       })
       .getOne()
 
@@ -90,7 +90,19 @@ export class TypeORMItemRepository implements ItemRepositoryInterface {
       return null
     }
 
-    return this.mapper.toDomain(persistence)
+    const item = this.mapper.toDomain(persistence)
+
+    const keySystemAssociation = await this.keySystemAssociationRepository.findByItemUuid(uuid)
+    if (keySystemAssociation) {
+      item.props.keySystemAssociation = keySystemAssociation
+    }
+
+    const sharedVaultAssociation = await this.sharedVaultAssociationRepository.findByItemUuid(uuid)
+    if (sharedVaultAssociation) {
+      item.props.sharedVaultAssociation = sharedVaultAssociation
+    }
+
+    return item
   }
 
   async findDatesForComputingIntegrityHash(userUuid: string): Promise<Array<{ updated_at_timestamp: number }>> {

+ 16 - 1
packages/syncing-server/src/Infra/TypeORM/TypeORMKeySystemAssociationRepository.ts

@@ -1,5 +1,5 @@
 import { Repository } from 'typeorm'
-import { MapperInterface } from '@standardnotes/domain-core'
+import { MapperInterface, Uuid } from '@standardnotes/domain-core'
 
 import { KeySystemAssociation } from '../../Domain/KeySystem/KeySystemAssociation'
 import { KeySystemAssociationRepositoryInterface } from '../../Domain/KeySystem/KeySystemAssociationRepositoryInterface'
@@ -11,6 +11,21 @@ export class TypeORMKeySystemAssociationRepository implements KeySystemAssociati
     private mapper: MapperInterface<KeySystemAssociation, TypeORMKeySystemAssociation>,
   ) {}
 
+  async findByItemUuid(itemUuid: Uuid): Promise<KeySystemAssociation | null> {
+    const persistence = await this.ormRepository
+      .createQueryBuilder('key_system_association')
+      .where('key_system_association.item_uuid = :itemUuid', {
+        itemUuid: itemUuid.value,
+      })
+      .getOne()
+
+    if (persistence === null) {
+      return null
+    }
+
+    return this.mapper.toDomain(persistence)
+  }
+
   async save(keySystemAssociation: KeySystemAssociation): Promise<void> {
     await this.ormRepository.save(this.mapper.toProjection(keySystemAssociation))
   }

+ 1 - 1
packages/syncing-server/src/Infra/TypeORM/TypeORMSharedVault.ts

@@ -9,7 +9,7 @@ export class TypeORMSharedVault {
     name: 'user_uuid',
     length: 36,
   })
-  @Index('index_shared_vaults_on_user_uuid')
+  @Index('user_uuid_on_shared_vaults')
   declare userUuid: string
 
   @Column({

+ 16 - 1
packages/syncing-server/src/Infra/TypeORM/TypeORMSharedVaultAssociationRepository.ts

@@ -1,5 +1,5 @@
 import { Repository } from 'typeorm'
-import { MapperInterface } from '@standardnotes/domain-core'
+import { MapperInterface, Uuid } from '@standardnotes/domain-core'
 
 import { SharedVaultAssociation } from '../../Domain/SharedVault/SharedVaultAssociation'
 import { SharedVaultAssociationRepositoryInterface } from '../../Domain/SharedVault/SharedVaultAssociationRepositoryInterface'
@@ -11,6 +11,21 @@ export class TypeORMSharedVaultAssociationRepository implements SharedVaultAssoc
     private mapper: MapperInterface<SharedVaultAssociation, TypeORMSharedVaultAssociation>,
   ) {}
 
+  async findByItemUuid(itemUuid: Uuid): Promise<SharedVaultAssociation | null> {
+    const persistence = await this.ormRepository
+      .createQueryBuilder('shared_vault_association')
+      .where('shared_vault_association.item_uuid = :itemUuid', {
+        itemUuid: itemUuid.value,
+      })
+      .getOne()
+
+    if (persistence === null) {
+      return null
+    }
+
+    return this.mapper.toDomain(persistence)
+  }
+
   async save(sharedVaultAssociation: SharedVaultAssociation): Promise<void> {
     await this.ormRepository.save(this.mapper.toProjection(sharedVaultAssociation))
   }

+ 4 - 1
packages/syncing-server/src/Infra/TypeORM/TypeORMSharedVaultInvite.ts

@@ -1,4 +1,4 @@
-import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
+import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'
 
 @Entity({ name: 'shared_vault_invites' })
 export class TypeORMSharedVaultInvite {
@@ -9,18 +9,21 @@ export class TypeORMSharedVaultInvite {
     name: 'shared_vault_uuid',
     length: 36,
   })
+  @Index('shared_vault_uuid_on_shared_vault_invites')
   declare sharedVaultUuid: string
 
   @Column({
     name: 'user_uuid',
     length: 36,
   })
+  @Index('user_uuid_on_shared_vault_invites')
   declare userUuid: string
 
   @Column({
     name: 'sender_uuid',
     length: 36,
   })
+  @Index('sender_uuid_on_shared_vault_invites')
   declare senderUuid: string
 
   @Column({

+ 3 - 1
packages/syncing-server/src/Infra/TypeORM/TypeORMSharedVaultUser.ts

@@ -1,4 +1,4 @@
-import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
+import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'
 
 @Entity({ name: 'shared_vault_users' })
 export class TypeORMSharedVaultUser {
@@ -9,12 +9,14 @@ export class TypeORMSharedVaultUser {
     name: 'shared_vault_uuid',
     length: 36,
   })
+  @Index('shared_vault_uuid_on_shared_vault_users')
   declare sharedVaultUuid: string
 
   @Column({
     name: 'user_uuid',
     length: 36,
   })
+  @Index('user_uuid_on_shared_vault_users')
   declare userUuid: string
 
   @Column({