Browse Source

fix(syncing-server): persisting aggregate changes from root (#674)

Karol Sójko 1 year ago
parent
commit
c34f548e45

+ 16 - 1
packages/domain-core/src/Domain/Core/Aggregate.ts

@@ -1,5 +1,20 @@
 /* istanbul ignore file */
 /* istanbul ignore file */
 
 
+import { Change } from './Change'
 import { Entity } from './Entity'
 import { Entity } from './Entity'
 
 
-export abstract class Aggregate<T> extends Entity<T> {}
+export abstract class Aggregate<T> extends Entity<T> {
+  private changesOnAggregateRoot: Change[] = []
+
+  addChange(change: Change): void {
+    this.changesOnAggregateRoot.push(change)
+  }
+
+  flushChanges(): void {
+    this.changesOnAggregateRoot = []
+  }
+
+  getChanges(): Change[] {
+    return this.changesOnAggregateRoot
+  }
+}

+ 26 - 0
packages/domain-core/src/Domain/Core/Change.ts

@@ -0,0 +1,26 @@
+/* istanbul ignore file */
+
+import { ChangeProps } from './ChangeProps'
+import { Result } from './Result'
+
+export class Change {
+  static readonly TYPES = {
+    Add: 'add',
+    Remove: 'remove',
+    Modify: 'modify',
+  }
+
+  public readonly props: ChangeProps
+
+  constructor(props: ChangeProps) {
+    this.props = Object.freeze(props)
+  }
+
+  static create(props: ChangeProps): Result<Change> {
+    if (!Object.values(Change.TYPES).includes(props.changeType)) {
+      return Result.fail('Invalid change type')
+    }
+
+    return Result.ok(new Change(props))
+  }
+}

+ 9 - 0
packages/domain-core/src/Domain/Core/ChangeProps.ts

@@ -0,0 +1,9 @@
+/* istanbul ignore file */
+
+import { Entity } from './Entity'
+
+export interface ChangeProps {
+  aggregateRootUuid: string
+  changeType: string
+  changeData: Entity<unknown>
+}

+ 2 - 0
packages/domain-core/src/Domain/index.ts

@@ -27,6 +27,8 @@ export * from './Common/Uuid'
 export * from './Common/UuidProps'
 export * from './Common/UuidProps'
 
 
 export * from './Core/Aggregate'
 export * from './Core/Aggregate'
+export * from './Core/Change'
+export * from './Core/ChangeProps'
 export * from './Core/Entity'
 export * from './Core/Entity'
 export * from './Core/Id'
 export * from './Core/Id'
 export * from './Core/Result'
 export * from './Core/Result'

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

@@ -2,6 +2,7 @@ import { ContentType, Dates, Timestamps, UniqueEntityId, Uuid } from '@standardn
 
 
 import { Item } from './Item'
 import { Item } from './Item'
 import { SharedVaultAssociation } from '../SharedVault/SharedVaultAssociation'
 import { SharedVaultAssociation } from '../SharedVault/SharedVaultAssociation'
+import { KeySystemAssociation } from '../KeySystem/KeySystemAssociation'
 
 
 describe('Item', () => {
 describe('Item', () => {
   it('should create an aggregate', () => {
   it('should create an aggregate', () => {
@@ -97,4 +98,155 @@ describe('Item', () => {
         .isAssociatedWithSharedVault(Uuid.create('00000000-0000-0000-0000-000000000000').getValue()),
         .isAssociatedWithSharedVault(Uuid.create('00000000-0000-0000-0000-000000000000').getValue()),
     ).toBeFalsy()
     ).toBeFalsy()
   })
   })
+
+  it('should tell if an item is associated with a key system', () => {
+    const entityOrError = 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(),
+      keySystemAssociation: KeySystemAssociation.create({
+        itemUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+        keySystemIdentifier: 'key-system-identifier',
+        timestamps: Timestamps.create(123, 123).getValue(),
+      }).getValue(),
+    })
+
+    expect(entityOrError.isFailed()).toBeFalsy()
+    expect(entityOrError.getValue().isAssociatedWithKeySystem('key-system-identifier')).toBeTruthy()
+  })
+
+  it('should tell that an item is not associated with a key system', () => {
+    const entityOrError = 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(),
+    })
+
+    expect(entityOrError.isFailed()).toBeFalsy()
+    expect(entityOrError.getValue().isAssociatedWithKeySystem('key-system-identifier')).toBeFalsy()
+  })
+
+  it('should set shared vault association', () => {
+    const 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 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(),
+    }).getValue()
+
+    entity.setSharedVaultAssociation(sharedVaultAssociation)
+
+    expect(entity.props.sharedVaultAssociation).toEqual(sharedVaultAssociation)
+    expect(entity.getChanges()).toHaveLength(1)
+  })
+
+  it('should unset a shared vault association', () => {
+    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(),
+      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(),
+    }).getValue()
+
+    entity.unsetSharedVaultAssociation()
+
+    expect(entity.props.sharedVaultAssociation).toBeUndefined()
+    expect(entity.getChanges()).toHaveLength(1)
+  })
+
+  it('should set key system association', () => {
+    const keySystemAssociation = KeySystemAssociation.create({
+      itemUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+      keySystemIdentifier: 'key-system-identifier',
+      timestamps: Timestamps.create(123, 123).getValue(),
+    }).getValue()
+
+    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(),
+    }).getValue()
+
+    entity.setKeySystemAssociation(keySystemAssociation)
+
+    expect(entity.props.keySystemAssociation).toEqual(keySystemAssociation)
+    expect(entity.getChanges()).toHaveLength(1)
+  })
+
+  it('should unset a key system association', () => {
+    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(),
+      keySystemAssociation: KeySystemAssociation.create({
+        itemUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+        keySystemIdentifier: 'key-system-identifier',
+        timestamps: Timestamps.create(123, 123).getValue(),
+      }).getValue(),
+    }).getValue()
+
+    entity.unsetKeySystemAssociation()
+
+    expect(entity.props.keySystemAssociation).toBeUndefined()
+    expect(entity.getChanges()).toHaveLength(1)
+  })
 })
 })

+ 64 - 8
packages/syncing-server/src/Domain/Item/Item.ts

@@ -1,8 +1,23 @@
-import { Aggregate, Result, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
+import { Aggregate, Change, Result, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
 
 
 import { ItemProps } from './ItemProps'
 import { ItemProps } from './ItemProps'
+import { SharedVaultAssociation } from '../SharedVault/SharedVaultAssociation'
+import { KeySystemAssociation } from '../KeySystem/KeySystemAssociation'
 
 
 export class Item extends Aggregate<ItemProps> {
 export class Item extends Aggregate<ItemProps> {
+  private constructor(props: ItemProps, id?: UniqueEntityId) {
+    super(props, id)
+  }
+
+  static create(props: ItemProps, id?: UniqueEntityId): Result<Item> {
+    if (!props.contentSize) {
+      const contentSize = Buffer.byteLength(JSON.stringify(props))
+      props.contentSize = contentSize
+    }
+
+    return Result.ok<Item>(new Item(props, id))
+  }
+
   get uuid(): Uuid {
   get uuid(): Uuid {
     const uuidOrError = Uuid.create(this._id.toString())
     const uuidOrError = Uuid.create(this._id.toString())
     if (uuidOrError.isFailed()) {
     if (uuidOrError.isFailed()) {
@@ -40,16 +55,57 @@ export class Item extends Aggregate<ItemProps> {
     return this.props.keySystemAssociation.props.keySystemIdentifier === keySystemIdentifier
     return this.props.keySystemAssociation.props.keySystemIdentifier === keySystemIdentifier
   }
   }
 
 
-  private constructor(props: ItemProps, id?: UniqueEntityId) {
-    super(props, id)
+  setSharedVaultAssociation(sharedVaultAssociation: SharedVaultAssociation): void {
+    this.addChange(
+      Change.create({
+        aggregateRootUuid: this.uuid.value,
+        changeType: this.props.sharedVaultAssociation ? Change.TYPES.Modify : Change.TYPES.Add,
+        changeData: sharedVaultAssociation,
+      }).getValue(),
+    )
+
+    this.props.sharedVaultAssociation = sharedVaultAssociation
   }
   }
 
 
-  static create(props: ItemProps, id?: UniqueEntityId): Result<Item> {
-    if (!props.contentSize) {
-      const contentSize = Buffer.byteLength(JSON.stringify(props))
-      props.contentSize = contentSize
+  unsetSharedVaultAssociation(): void {
+    if (!this.props.sharedVaultAssociation) {
+      return
     }
     }
 
 
-    return Result.ok<Item>(new Item(props, id))
+    this.addChange(
+      Change.create({
+        aggregateRootUuid: this.uuid.value,
+        changeType: Change.TYPES.Remove,
+        changeData: this.props.sharedVaultAssociation,
+      }).getValue(),
+    )
+    this.props.sharedVaultAssociation = undefined
+  }
+
+  setKeySystemAssociation(keySystemAssociation: KeySystemAssociation): void {
+    this.addChange(
+      Change.create({
+        aggregateRootUuid: this.uuid.value,
+        changeType: this.props.keySystemAssociation ? Change.TYPES.Modify : Change.TYPES.Add,
+        changeData: keySystemAssociation,
+      }).getValue(),
+    )
+
+    this.props.keySystemAssociation = keySystemAssociation
+  }
+
+  unsetKeySystemAssociation(): void {
+    if (!this.props.keySystemAssociation) {
+      return
+    }
+
+    this.addChange(
+      Change.create({
+        aggregateRootUuid: this.uuid.value,
+        changeType: Change.TYPES.Remove,
+        changeData: this.props.keySystemAssociation,
+      }).getValue(),
+    )
+    this.props.keySystemAssociation = undefined
   }
   }
 }
 }

+ 1 - 14
packages/syncing-server/src/Domain/Item/SaveRule/SharedVaultFilter.spec.ts

@@ -72,24 +72,11 @@ describe('SharedVaultFilter', () => {
       .mockResolvedValueOnce(null)
       .mockResolvedValueOnce(null)
   })
   })
 
 
-  it('should return as passed if the item hash does not represent a shared vault item', async () => {
+  it('should return as passed if the item hash does not represent a shared vault item and existing item is not a shared vault item', async () => {
     itemHash = ItemHash.create({
     itemHash = ItemHash.create({
       ...itemHash.props,
       ...itemHash.props,
       shared_vault_uuid: null,
       shared_vault_uuid: null,
     }).getValue()
     }).getValue()
-
-    const filter = createFilter()
-    const result = await filter.check({
-      apiVersion: '001',
-      existingItem: existingItem,
-      itemHash: itemHash,
-      userUuid: '00000000-0000-0000-0000-000000000000',
-    })
-
-    expect(result.passed).toBe(true)
-  })
-
-  it('should return as passed if the item is not a shared vault item', async () => {
     existingItem = Item.create({
     existingItem = Item.create({
       ...existingItem.props,
       ...existingItem.props,
       sharedVaultAssociation: undefined,
       sharedVaultAssociation: undefined,

+ 1 - 1
packages/syncing-server/src/Domain/Item/SaveRule/SharedVaultFilter.ts

@@ -16,7 +16,7 @@ export class SharedVaultFilter implements ItemSaveRuleInterface {
   ) {}
   ) {}
 
 
   async check(dto: ItemSaveValidationDTO): Promise<ItemSaveRuleResult> {
   async check(dto: ItemSaveValidationDTO): Promise<ItemSaveRuleResult> {
-    if (!dto.itemHash.representsASharedVaultItem() || !dto.existingItem?.isAssociatedWithASharedVault()) {
+    if (!dto.itemHash.representsASharedVaultItem() && !dto.existingItem?.isAssociatedWithASharedVault()) {
       return {
       return {
         passed: true,
         passed: true,
       }
       }

+ 23 - 27
packages/syncing-server/src/Domain/UseCase/Syncing/SaveNewItem/SaveNewItem.ts

@@ -87,7 +87,27 @@ export class SaveNewItem implements UseCaseInterface<Item> {
     }
     }
     const timestamps = timestampsOrError.getValue()
     const timestamps = timestampsOrError.getValue()
 
 
-    let sharedVaultAssociation = undefined
+    const itemOrError = Item.create(
+      {
+        updatedWithSession,
+        content: dto.itemHash.props.content ?? null,
+        userUuid,
+        contentType,
+        encItemKey: dto.itemHash.props.enc_item_key ?? null,
+        authHash: dto.itemHash.props.auth_hash ?? null,
+        itemsKeyId: dto.itemHash.props.items_key_id ?? null,
+        duplicateOf,
+        deleted: dto.itemHash.props.deleted ?? false,
+        dates,
+        timestamps,
+      },
+      new UniqueEntityId(uuid.value),
+    )
+    if (itemOrError.isFailed()) {
+      return Result.fail(itemOrError.getError())
+    }
+    const newItem = itemOrError.getValue()
+
     if (dto.itemHash.representsASharedVaultItem()) {
     if (dto.itemHash.representsASharedVaultItem()) {
       const sharedVaultAssociationOrError = SharedVaultAssociation.create({
       const sharedVaultAssociationOrError = SharedVaultAssociation.create({
         lastEditedBy: userUuid,
         lastEditedBy: userUuid,
@@ -101,10 +121,9 @@ export class SaveNewItem implements UseCaseInterface<Item> {
       if (sharedVaultAssociationOrError.isFailed()) {
       if (sharedVaultAssociationOrError.isFailed()) {
         return Result.fail(sharedVaultAssociationOrError.getError())
         return Result.fail(sharedVaultAssociationOrError.getError())
       }
       }
-      sharedVaultAssociation = sharedVaultAssociationOrError.getValue()
+      newItem.setSharedVaultAssociation(sharedVaultAssociationOrError.getValue())
     }
     }
 
 
-    let keySystemAssociation = undefined
     if (dto.itemHash.hasDedicatedKeySystemAssociation()) {
     if (dto.itemHash.hasDedicatedKeySystemAssociation()) {
       const keySystemIdentifiedValidationResult = Validator.isNotEmptyString(dto.itemHash.props.key_system_identifier)
       const keySystemIdentifiedValidationResult = Validator.isNotEmptyString(dto.itemHash.props.key_system_identifier)
       if (keySystemIdentifiedValidationResult.isFailed()) {
       if (keySystemIdentifiedValidationResult.isFailed()) {
@@ -123,32 +142,9 @@ export class SaveNewItem implements UseCaseInterface<Item> {
       if (keySystemAssociationOrError.isFailed()) {
       if (keySystemAssociationOrError.isFailed()) {
         return Result.fail(keySystemAssociationOrError.getError())
         return Result.fail(keySystemAssociationOrError.getError())
       }
       }
-      keySystemAssociation = keySystemAssociationOrError.getValue()
+      newItem.setKeySystemAssociation(keySystemAssociationOrError.getValue())
     }
     }
 
 
-    const itemOrError = Item.create(
-      {
-        updatedWithSession,
-        content: dto.itemHash.props.content ?? null,
-        userUuid,
-        contentType,
-        encItemKey: dto.itemHash.props.enc_item_key ?? null,
-        authHash: dto.itemHash.props.auth_hash ?? null,
-        itemsKeyId: dto.itemHash.props.items_key_id ?? null,
-        duplicateOf,
-        deleted: dto.itemHash.props.deleted ?? false,
-        dates,
-        timestamps,
-        keySystemAssociation,
-        sharedVaultAssociation,
-      },
-      new UniqueEntityId(uuid.value),
-    )
-    if (itemOrError.isFailed()) {
-      return Result.fail(itemOrError.getError())
-    }
-    const newItem = itemOrError.getValue()
-
     await this.itemRepository.save(newItem)
     await this.itemRepository.save(newItem)
 
 
     if (contentType.value !== null && [ContentType.TYPES.Note, ContentType.TYPES.File].includes(contentType.value)) {
     if (contentType.value !== null && [ContentType.TYPES.Note, ContentType.TYPES.File].includes(contentType.value)) {

+ 27 - 1
packages/syncing-server/src/Domain/UseCase/Syncing/SyncItems/SyncItems.spec.ts

@@ -177,7 +177,7 @@ describe('SyncItems', () => {
     })
     })
   })
   })
 
 
-  it('should sync items and return items keys on top for first sync', async () => {
+  it('should sync items and return items keys on top for first sync that is not a shared vault exclusive sync', async () => {
     const result = await createUseCase().execute({
     const result = await createUseCase().execute({
       userUuid: '1-2-3',
       userUuid: '1-2-3',
       itemHashes: [itemHash],
       itemHashes: [itemHash],
@@ -202,6 +202,32 @@ describe('SyncItems', () => {
     })
     })
   })
   })
 
 
+  it('should sync items and not return items keys on top for first sync that is a shared vault exclusive sync', 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',
+      sharedVaultUuids: ['00000000-0000-0000-0000-000000000000'],
+    })
+    expect(result.getValue()).toEqual({
+      conflicts: [],
+      cursorToken: 'asdzxc',
+      retrievedItems: [item1],
+      savedItems: [item2],
+      syncToken: 'qwerty',
+      sharedVaults: [],
+      sharedVaultInvites: [],
+      notifications: [],
+      messages: [],
+    })
+  })
+
   it('should sync items and return filtered out sync conflicts for consecutive sync operations', async () => {
   it('should sync items and return filtered out sync conflicts for consecutive sync operations', async () => {
     getItemsUseCase.execute = jest.fn().mockReturnValue(
     getItemsUseCase.execute = jest.fn().mockReturnValue(
       Result.ok({
       Result.ok({

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

@@ -50,7 +50,8 @@ export class SyncItems implements UseCaseInterface<SyncItemsResponse> {
     const saveItemsResult = saveItemsResultOrError.getValue()
     const saveItemsResult = saveItemsResultOrError.getValue()
 
 
     let retrievedItems = this.filterOutSyncConflictsForConsecutiveSyncs(getItemsResult.items, saveItemsResult.conflicts)
     let retrievedItems = this.filterOutSyncConflictsForConsecutiveSyncs(getItemsResult.items, saveItemsResult.conflicts)
-    if (this.isFirstSync(dto)) {
+    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, retrievedItems)
     }
     }
 
 

+ 17 - 13
packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem.spec.ts

@@ -350,12 +350,14 @@ describe('UpdateExistingItem', () => {
         shared_vault_uuid: '00000000-0000-0000-0000-000000000000',
         shared_vault_uuid: '00000000-0000-0000-0000-000000000000',
       }).getValue()
       }).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()
+      item1.setSharedVaultAssociation(
+        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 idBefore = item1.props.sharedVaultAssociation?.id.toString()
 
 
       const result = await useCase.execute({
       const result = await useCase.execute({
@@ -368,7 +370,7 @@ describe('UpdateExistingItem', () => {
       expect(result.isFailed()).toBeFalsy()
       expect(result.isFailed()).toBeFalsy()
 
 
       expect(item1.props.sharedVaultAssociation).not.toBeUndefined()
       expect(item1.props.sharedVaultAssociation).not.toBeUndefined()
-      expect(item1.props.sharedVaultAssociation.id.toString()).toEqual(idBefore)
+      expect((item1.props.sharedVaultAssociation as SharedVaultAssociation).id.toString()).toEqual(idBefore)
     })
     })
 
 
     it('should return error if shared vault association could not be created', async () => {
     it('should return error if shared vault association could not be created', async () => {
@@ -528,11 +530,13 @@ describe('UpdateExistingItem', () => {
         key_system_identifier: '00000000-0000-0000-0000-000000000000',
         key_system_identifier: '00000000-0000-0000-0000-000000000000',
       }).getValue()
       }).getValue()
 
 
-      item1.props.keySystemAssociation = KeySystemAssociation.create({
-        itemUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
-        keySystemIdentifier: '00000000-0000-0000-0000-000000000000',
-        timestamps: Timestamps.create(123, 123).getValue(),
-      }).getValue()
+      item1.setKeySystemAssociation(
+        KeySystemAssociation.create({
+          itemUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+          keySystemIdentifier: '00000000-0000-0000-0000-000000000000',
+          timestamps: Timestamps.create(123, 123).getValue(),
+        }).getValue(),
+      )
       const idBefore = item1.props.keySystemAssociation?.id.toString()
       const idBefore = item1.props.keySystemAssociation?.id.toString()
 
 
       const result = await useCase.execute({
       const result = await useCase.execute({
@@ -545,7 +549,7 @@ describe('UpdateExistingItem', () => {
       expect(result.isFailed()).toBeFalsy()
       expect(result.isFailed()).toBeFalsy()
 
 
       expect(item1.props.keySystemAssociation).not.toBeUndefined()
       expect(item1.props.keySystemAssociation).not.toBeUndefined()
-      expect(item1.props.keySystemAssociation.id.toString()).toEqual(idBefore)
+      expect((item1.props.keySystemAssociation as KeySystemAssociation).id.toString()).toEqual(idBefore)
     })
     })
 
 
     it('should return error if key system identifier is invalid', async () => {
     it('should return error if key system identifier is invalid', async () => {

+ 22 - 14
packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem.ts

@@ -147,7 +147,7 @@ export class UpdateExistingItem implements UseCaseInterface<Item> {
         return Result.fail(sharedVaultAssociationOrError.getError())
         return Result.fail(sharedVaultAssociationOrError.getError())
       }
       }
 
 
-      dto.existingItem.props.sharedVaultAssociation = sharedVaultAssociationOrError.getValue()
+      dto.existingItem.setSharedVaultAssociation(sharedVaultAssociationOrError.getValue())
 
 
       const sharedVaultOperationOrError = await this.determineSharedVaultOperationOnItem.execute({
       const sharedVaultOperationOrError = await this.determineSharedVaultOperationOnItem.execute({
         existingItem: dto.existingItem,
         existingItem: dto.existingItem,
@@ -158,31 +158,39 @@ export class UpdateExistingItem implements UseCaseInterface<Item> {
         return Result.fail(sharedVaultOperationOrError.getError())
         return Result.fail(sharedVaultOperationOrError.getError())
       }
       }
       sharedVaultOperation = sharedVaultOperationOrError.getValue()
       sharedVaultOperation = sharedVaultOperationOrError.getValue()
+    } else {
+      dto.existingItem.unsetSharedVaultAssociation()
     }
     }
 
 
-    if (
-      dto.itemHash.hasDedicatedKeySystemAssociation() &&
-      !dto.existingItem.isAssociatedWithKeySystem(dto.itemHash.props.key_system_identifier as string)
-    ) {
+    if (dto.itemHash.hasDedicatedKeySystemAssociation()) {
       const keySystemIdentifiedValidationResult = Validator.isNotEmptyString(dto.itemHash.props.key_system_identifier)
       const keySystemIdentifiedValidationResult = Validator.isNotEmptyString(dto.itemHash.props.key_system_identifier)
       if (keySystemIdentifiedValidationResult.isFailed()) {
       if (keySystemIdentifiedValidationResult.isFailed()) {
         return Result.fail(keySystemIdentifiedValidationResult.getError())
         return Result.fail(keySystemIdentifiedValidationResult.getError())
       }
       }
       const keySystemIdentifier = dto.itemHash.props.key_system_identifier as string
       const keySystemIdentifier = dto.itemHash.props.key_system_identifier as string
 
 
-      const keySystemAssociationOrError = KeySystemAssociation.create({
-        itemUuid: Uuid.create(dto.existingItem.id.toString()).getValue(),
-        timestamps: Timestamps.create(
-          this.timer.getTimestampInMicroseconds(),
-          this.timer.getTimestampInMicroseconds(),
-        ).getValue(),
-        keySystemIdentifier,
-      })
+      const keySystemAssociationOrError = KeySystemAssociation.create(
+        {
+          itemUuid: Uuid.create(dto.existingItem.id.toString()).getValue(),
+          timestamps: Timestamps.create(
+            this.timer.getTimestampInMicroseconds(),
+            this.timer.getTimestampInMicroseconds(),
+          ).getValue(),
+          keySystemIdentifier,
+        },
+        new UniqueEntityId(
+          dto.existingItem.props.keySystemAssociation
+            ? dto.existingItem.props.keySystemAssociation.id.toString()
+            : undefined,
+        ),
+      )
       if (keySystemAssociationOrError.isFailed()) {
       if (keySystemAssociationOrError.isFailed()) {
         return Result.fail(keySystemAssociationOrError.getError())
         return Result.fail(keySystemAssociationOrError.getError())
       }
       }
 
 
-      dto.existingItem.props.keySystemAssociation = keySystemAssociationOrError.getValue()
+      dto.existingItem.setKeySystemAssociation(keySystemAssociationOrError.getValue())
+    } else {
+      dto.existingItem.unsetKeySystemAssociation()
     }
     }
 
 
     if (dto.itemHash.props.deleted === true) {
     if (dto.itemHash.props.deleted === true) {

+ 27 - 8
packages/syncing-server/src/Infra/TypeORM/TypeORMItemRepository.ts

@@ -1,6 +1,6 @@
 import { ReadStream } from 'fs'
 import { ReadStream } from 'fs'
 import { Repository, SelectQueryBuilder } from 'typeorm'
 import { Repository, SelectQueryBuilder } from 'typeorm'
-import { MapperInterface, Uuid } from '@standardnotes/domain-core'
+import { Change, MapperInterface, Uuid } from '@standardnotes/domain-core'
 
 
 import { Item } from '../../Domain/Item/Item'
 import { Item } from '../../Domain/Item/Item'
 import { ItemQuery } from '../../Domain/Item/ItemQuery'
 import { ItemQuery } from '../../Domain/Item/ItemQuery'
@@ -10,6 +10,8 @@ import { TypeORMItem } from './TypeORMItem'
 import { KeySystemAssociationRepositoryInterface } from '../../Domain/KeySystem/KeySystemAssociationRepositoryInterface'
 import { KeySystemAssociationRepositoryInterface } from '../../Domain/KeySystem/KeySystemAssociationRepositoryInterface'
 import { SharedVaultAssociationRepositoryInterface } from '../../Domain/SharedVault/SharedVaultAssociationRepositoryInterface'
 import { SharedVaultAssociationRepositoryInterface } from '../../Domain/SharedVault/SharedVaultAssociationRepositoryInterface'
 import { TypeORMSharedVaultAssociation } from './TypeORMSharedVaultAssociation'
 import { TypeORMSharedVaultAssociation } from './TypeORMSharedVaultAssociation'
+import { SharedVaultAssociation } from '../../Domain/SharedVault/SharedVaultAssociation'
+import { KeySystemAssociation } from '../../Domain/KeySystem/KeySystemAssociation'
 
 
 export class TypeORMItemRepository implements ItemRepositoryInterface {
 export class TypeORMItemRepository implements ItemRepositoryInterface {
   constructor(
   constructor(
@@ -24,13 +26,7 @@ export class TypeORMItemRepository implements ItemRepositoryInterface {
 
 
     await this.ormRepository.save(persistence)
     await this.ormRepository.save(persistence)
 
 
-    if (item.props.sharedVaultAssociation) {
-      await this.sharedVaultAssociationRepository.save(item.props.sharedVaultAssociation)
-    }
-
-    if (item.props.keySystemAssociation) {
-      await this.keySystemAssociationRepository.save(item.props.keySystemAssociation)
-    }
+    await this.persistAssociationChanges(item)
   }
   }
 
 
   async remove(item: Item): Promise<void> {
   async remove(item: Item): Promise<void> {
@@ -273,4 +269,27 @@ export class TypeORMItemRepository implements ItemRepositoryInterface {
       item.props.sharedVaultAssociation = sharedVaultAssociation
       item.props.sharedVaultAssociation = sharedVaultAssociation
     }
     }
   }
   }
+
+  private async persistAssociationChanges(item: Item): Promise<void> {
+    for (const change of item.getChanges()) {
+      if (change.props.changeData instanceof SharedVaultAssociation) {
+        if ([Change.TYPES.Add, Change.TYPES.Modify].includes(change.props.changeType)) {
+          await this.sharedVaultAssociationRepository.save(change.props.changeData)
+        }
+        if (change.props.changeType === Change.TYPES.Remove) {
+          await this.sharedVaultAssociationRepository.remove(change.props.changeData)
+        }
+      }
+      if (change.props.changeData instanceof KeySystemAssociation) {
+        if ([Change.TYPES.Add, Change.TYPES.Modify].includes(change.props.changeType)) {
+          await this.keySystemAssociationRepository.save(change.props.changeData)
+        }
+        if (change.props.changeType === Change.TYPES.Remove) {
+          await this.keySystemAssociationRepository.remove(change.props.changeData)
+        }
+      }
+    }
+
+    item.flushChanges()
+  }
 }
 }