Browse Source

feat(syncing-server): refactor syncing to decouple getting and saving items (#659)

* feat(syncing-server): refactor syncing to decouple getting and saving items

* fix(syncing-server): item hash http representation mapping

* fix(syncing-server): remove redundant specs for inversify express controller
Karol Sójko 1 year ago
parent
commit
cb74b23e45
71 changed files with 1366 additions and 1616 deletions
  1. 0 4
      packages/analytics/src/Domain/Statistics/StatisticMeasure.ts
  2. 0 4
      packages/analytics/src/Domain/Subscription/Subscription.ts
  3. 0 4
      packages/analytics/src/Domain/User/User.ts
  4. 0 4
      packages/auth/src/Domain/Authenticator/Authenticator.ts
  5. 0 4
      packages/auth/src/Domain/Authenticator/AuthenticatorChallenge.ts
  6. 0 4
      packages/auth/src/Domain/EmergencyAccess/EmergencyAccessInvitation.ts
  7. 0 4
      packages/auth/src/Domain/Session/SessionTrace.ts
  8. 0 4
      packages/domain-core/src/Domain/Cache/CacheEntry.ts
  9. 1 6
      packages/domain-core/src/Domain/Core/Aggregate.ts
  10. 4 0
      packages/domain-core/src/Domain/Core/Entity.ts
  11. 0 4
      packages/revisions/src/Domain/Revision/Revision.ts
  12. 0 4
      packages/revisions/src/Domain/Revision/RevisionMetadata.ts
  13. 61 37
      packages/syncing-server/src/Bootstrap/Container.ts
  14. 3 1
      packages/syncing-server/src/Bootstrap/Types.ts
  15. 0 6
      packages/syncing-server/src/Domain/Item/GetItemsResult.ts
  16. 0 4
      packages/syncing-server/src/Domain/Item/Item.ts
  17. 12 13
      packages/syncing-server/src/Domain/Item/ItemHash.ts
  18. 17 0
      packages/syncing-server/src/Domain/Item/ItemHashProps.ts
  19. 0 785
      packages/syncing-server/src/Domain/Item/ItemService.spec.ts
  20. 0 239
      packages/syncing-server/src/Domain/Item/ItemService.ts
  21. 0 11
      packages/syncing-server/src/Domain/Item/ItemServiceInterface.ts
  22. 0 8
      packages/syncing-server/src/Domain/Item/SaveItemsResult.ts
  23. 16 12
      packages/syncing-server/src/Domain/Item/SaveRule/ContentFilter.spec.ts
  24. 2 2
      packages/syncing-server/src/Domain/Item/SaveRule/ContentFilter.ts
  25. 19 12
      packages/syncing-server/src/Domain/Item/SaveRule/ContentTypeFilter.spec.ts
  26. 1 1
      packages/syncing-server/src/Domain/Item/SaveRule/ContentTypeFilter.ts
  27. 49 20
      packages/syncing-server/src/Domain/Item/SaveRule/OwnershipFilter.spec.ts
  28. 52 28
      packages/syncing-server/src/Domain/Item/SaveRule/TimeDifferenceFilter.spec.ts
  29. 4 4
      packages/syncing-server/src/Domain/Item/SaveRule/TimeDifferenceFilter.ts
  30. 0 4
      packages/syncing-server/src/Domain/Message/Message.ts
  31. 0 4
      packages/syncing-server/src/Domain/Notifications/Notification.ts
  32. 18 0
      packages/syncing-server/src/Domain/SharedVault/Item/SharedVaultItem.spec.ts
  33. 13 0
      packages/syncing-server/src/Domain/SharedVault/Item/SharedVaultItem.ts
  34. 9 0
      packages/syncing-server/src/Domain/SharedVault/Item/SharedVaultItemProps.ts
  35. 8 0
      packages/syncing-server/src/Domain/SharedVault/Item/SharedVaultItemRepositoryInterface.ts
  36. 2 1
      packages/syncing-server/src/Domain/SharedVault/SharedVault.spec.ts
  37. 2 6
      packages/syncing-server/src/Domain/SharedVault/SharedVault.ts
  38. 3 0
      packages/syncing-server/src/Domain/SharedVault/SharedVaultProps.ts
  39. 0 4
      packages/syncing-server/src/Domain/SharedVault/User/Invite/SharedVaultInvite.ts
  40. 0 4
      packages/syncing-server/src/Domain/SharedVault/User/SharedVaultUser.ts
  41. 1 0
      packages/syncing-server/src/Domain/UseCase/SharedVaults/CreateSharedVault/CreateSharedVault.ts
  42. 1 0
      packages/syncing-server/src/Domain/UseCase/SharedVaults/CreateSharedVaultFileValetToken/CreateSharedVaultFileValetToken.spec.ts
  43. 2 0
      packages/syncing-server/src/Domain/UseCase/SharedVaults/DeleteSharedVault/DeleteSharedVault.spec.ts
  44. 2 0
      packages/syncing-server/src/Domain/UseCase/SharedVaults/GetSharedVaultUsers/GetSharedVaultUsers.spec.ts
  45. 1 0
      packages/syncing-server/src/Domain/UseCase/SharedVaults/GetSharedVaults/GetSharedVaults.spec.ts
  46. 2 0
      packages/syncing-server/src/Domain/UseCase/SharedVaults/InviteUserToSharedVault/InviteUserToSharedVault.spec.ts
  47. 2 0
      packages/syncing-server/src/Domain/UseCase/SharedVaults/RemoveUserFromSharedVault/RemoveUserFromSharedVault.spec.ts
  48. 152 0
      packages/syncing-server/src/Domain/UseCase/Syncing/GetItems/GetItems.spec.ts
  49. 95 0
      packages/syncing-server/src/Domain/UseCase/Syncing/GetItems/GetItems.ts
  50. 1 1
      packages/syncing-server/src/Domain/UseCase/Syncing/GetItems/GetItemsDTO.ts
  51. 6 0
      packages/syncing-server/src/Domain/UseCase/Syncing/GetItems/GetItemsResult.ts
  52. 275 0
      packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItems.spec.ts
  53. 145 0
      packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItems.ts
  54. 2 2
      packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItemsDTO.ts
  55. 8 0
      packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItemsResult.ts
  56. 33 12
      packages/syncing-server/src/Domain/UseCase/Syncing/SaveNewItem/SaveNewItem.spec.ts
  57. 14 14
      packages/syncing-server/src/Domain/UseCase/Syncing/SaveNewItem/SaveNewItem.ts
  58. 96 38
      packages/syncing-server/src/Domain/UseCase/Syncing/SyncItems/SyncItems.spec.ts
  59. 39 6
      packages/syncing-server/src/Domain/UseCase/Syncing/SyncItems/SyncItems.ts
  60. 29 26
      packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem.spec.ts
  61. 20 20
      packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem.ts
  62. 16 2
      packages/syncing-server/src/Infra/InversifyExpressUtils/HomeServer/HomeServerItemsController.ts
  63. 0 238
      packages/syncing-server/src/Infra/InversifyExpressUtils/InversifyExpressItemsController.spec.ts
  64. 45 0
      packages/syncing-server/src/Infra/TypeORM/TypeORMSharedVaultItem.ts
  65. 30 0
      packages/syncing-server/src/Infra/TypeORM/TypeORMSharedVaultItemRepository.ts
  66. 7 1
      packages/syncing-server/src/Infra/TypeORM/TypeORMSharedVaultRepository.ts
  67. 10 2
      packages/syncing-server/src/Mapping/Http/ItemConflictHttpMapper.ts
  68. 2 2
      packages/syncing-server/src/Mapping/Http/ItemConflictHttpRepresentation.ts
  69. 16 0
      packages/syncing-server/src/Mapping/Http/ItemHashHttpMapper.ts
  70. 17 0
      packages/syncing-server/src/Mapping/Http/ItemHashHttpRepresentation.ts
  71. 1 0
      packages/syncing-server/src/Mapping/Persistence/SharedVaultPersistenceMapper.ts

+ 0 - 4
packages/analytics/src/Domain/Statistics/StatisticMeasure.ts

@@ -3,10 +3,6 @@ import { Result, Entity, UniqueEntityId } from '@standardnotes/domain-core'
 import { StatisticMeasureProps } from './StatisticMeasureProps'
 
 export class StatisticMeasure extends Entity<StatisticMeasureProps> {
-  get id(): UniqueEntityId {
-    return this._id
-  }
-
   get name(): string {
     return this.props.name.value
   }

+ 0 - 4
packages/analytics/src/Domain/Subscription/Subscription.ts

@@ -3,10 +3,6 @@ import { Entity, Result, UniqueEntityId } from '@standardnotes/domain-core'
 import { SubscriptionProps } from './SubscriptionProps'
 
 export class Subscription extends Entity<SubscriptionProps> {
-  get id(): UniqueEntityId {
-    return this._id
-  }
-
   private constructor(props: SubscriptionProps, id?: UniqueEntityId) {
     super(props, id)
   }

+ 0 - 4
packages/analytics/src/Domain/User/User.ts

@@ -3,10 +3,6 @@ import { Entity, Result, UniqueEntityId } from '@standardnotes/domain-core'
 import { UserProps } from './UserProps'
 
 export class User extends Entity<UserProps> {
-  get id(): UniqueEntityId {
-    return this._id
-  }
-
   private constructor(props: UserProps, id?: UniqueEntityId) {
     super(props, id)
   }

+ 0 - 4
packages/auth/src/Domain/Authenticator/Authenticator.ts

@@ -3,10 +3,6 @@ import { Entity, Result, UniqueEntityId } from '@standardnotes/domain-core'
 import { AuthenticatorProps } from './AuthenticatorProps'
 
 export class Authenticator extends Entity<AuthenticatorProps> {
-  get id(): UniqueEntityId {
-    return this._id
-  }
-
   private constructor(props: AuthenticatorProps, id?: UniqueEntityId) {
     super(props, id)
   }

+ 0 - 4
packages/auth/src/Domain/Authenticator/AuthenticatorChallenge.ts

@@ -3,10 +3,6 @@ import { Entity, Result, UniqueEntityId } from '@standardnotes/domain-core'
 import { AuthenticatorChallengeProps } from './AuthenticatorChallengeProps'
 
 export class AuthenticatorChallenge extends Entity<AuthenticatorChallengeProps> {
-  get id(): UniqueEntityId {
-    return this._id
-  }
-
   private constructor(props: AuthenticatorChallengeProps, id?: UniqueEntityId) {
     super(props, id)
   }

+ 0 - 4
packages/auth/src/Domain/EmergencyAccess/EmergencyAccessInvitation.ts

@@ -3,10 +3,6 @@ import { Entity, Result, UniqueEntityId } from '@standardnotes/domain-core'
 import { EmergencyAccessInvitationProps } from './EmergencyAccessInvitationProps'
 
 export class EmergencyAccessInvitation extends Entity<EmergencyAccessInvitationProps> {
-  get id(): UniqueEntityId {
-    return this._id
-  }
-
   private constructor(props: EmergencyAccessInvitationProps, id?: UniqueEntityId) {
     super(props, id)
   }

+ 0 - 4
packages/auth/src/Domain/Session/SessionTrace.ts

@@ -3,10 +3,6 @@ import { Entity, Result, UniqueEntityId } from '@standardnotes/domain-core'
 import { SessionTraceProps } from './SessionTraceProps'
 
 export class SessionTrace extends Entity<SessionTraceProps> {
-  get id(): UniqueEntityId {
-    return this._id
-  }
-
   private constructor(props: SessionTraceProps, id?: UniqueEntityId) {
     super(props, id)
   }

+ 0 - 4
packages/domain-core/src/Domain/Cache/CacheEntry.ts

@@ -4,10 +4,6 @@ import { UniqueEntityId } from '../Core/UniqueEntityId'
 import { CacheEntryProps } from './CacheEntryProps'
 
 export class CacheEntry extends Entity<CacheEntryProps> {
-  get id(): UniqueEntityId {
-    return this._id
-  }
-
   private constructor(props: CacheEntryProps, id?: UniqueEntityId) {
     super(props, id)
   }

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

@@ -1,10 +1,5 @@
 /* istanbul ignore file */
 
 import { Entity } from './Entity'
-import { UniqueEntityId } from './UniqueEntityId'
 
-export abstract class Aggregate<T> extends Entity<T> {
-  get id(): UniqueEntityId {
-    return this._id
-  }
-}
+export abstract class Aggregate<T> extends Entity<T> {}

+ 4 - 0
packages/domain-core/src/Domain/Core/Entity.ts

@@ -9,6 +9,10 @@ export abstract class Entity<T> {
     this._id = id ? id : new UniqueEntityId()
   }
 
+  get id(): UniqueEntityId {
+    return this._id
+  }
+
   public equals(object?: Entity<T>): boolean {
     if (object == null || object == undefined) {
       return false

+ 0 - 4
packages/revisions/src/Domain/Revision/Revision.ts

@@ -3,10 +3,6 @@ import { Entity, Result, UniqueEntityId } from '@standardnotes/domain-core'
 import { RevisionProps } from './RevisionProps'
 
 export class Revision extends Entity<RevisionProps> {
-  get id(): UniqueEntityId {
-    return this._id
-  }
-
   private constructor(props: RevisionProps, id?: UniqueEntityId) {
     super(props, id)
   }

+ 0 - 4
packages/revisions/src/Domain/Revision/RevisionMetadata.ts

@@ -3,10 +3,6 @@ import { Entity, Result, UniqueEntityId } from '@standardnotes/domain-core'
 import { RevisionMetadataProps } from './RevisionMetadataProps'
 
 export class RevisionMetadata extends Entity<RevisionMetadataProps> {
-  get id(): UniqueEntityId {
-    return this._id
-  }
-
   private constructor(props: RevisionMetadataProps, id?: UniqueEntityId) {
     super(props, id)
   }

+ 61 - 37
packages/syncing-server/src/Bootstrap/Container.ts

@@ -23,8 +23,6 @@ import { Timer, TimerInterface } from '@standardnotes/time'
 import { ItemTransferCalculatorInterface } from '../Domain/Item/ItemTransferCalculatorInterface'
 import { ItemTransferCalculator } from '../Domain/Item/ItemTransferCalculator'
 import { ItemConflict } from '../Domain/Item/ItemConflict'
-import { ItemService } from '../Domain/Item/ItemService'
-import { ItemServiceInterface } from '../Domain/Item/ItemServiceInterface'
 import { ContentFilter } from '../Domain/Item/SaveRule/ContentFilter'
 import { ContentTypeFilter } from '../Domain/Item/SaveRule/ContentTypeFilter'
 import { OwnershipFilter } from '../Domain/Item/SaveRule/OwnershipFilter'
@@ -75,6 +73,11 @@ import { ItemBackupRepresentation } from '../Mapping/Backup/ItemBackupRepresenta
 import { ItemBackupMapper } from '../Mapping/Backup/ItemBackupMapper'
 import { SaveNewItem } from '../Domain/UseCase/Syncing/SaveNewItem/SaveNewItem'
 import { UpdateExistingItem } from '../Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem'
+import { GetItems } from '../Domain/UseCase/Syncing/GetItems/GetItems'
+import { SaveItems } from '../Domain/UseCase/Syncing/SaveItems/SaveItems'
+import { ItemHashHttpMapper } from '../Mapping/Http/ItemHashHttpMapper'
+import { ItemHash } from '../Domain/Item/ItemHash'
+import { ItemHashHttpRepresentation } from '../Mapping/Http/ItemHashHttpRepresentation'
 
 export class ContainerConfigLoader {
   private readonly DEFAULT_CONTENT_SIZE_TRANSFER_LIMIT = 10_000_000
@@ -209,6 +212,9 @@ export class ContainerConfigLoader {
     container
       .bind<MapperInterface<Item, TypeORMItem>>(TYPES.Sync_ItemPersistenceMapper)
       .toConstantValue(new ItemPersistenceMapper())
+    container
+      .bind<MapperInterface<ItemHash, ItemHashHttpRepresentation>>(TYPES.Sync_ItemHashHttpMapper)
+      .toConstantValue(new ItemHashHttpMapper())
     container
       .bind<MapperInterface<Item, ItemHttpRepresentation>>(TYPES.Sync_ItemHttpMapper)
       .toConstantValue(new ItemHttpMapper(container.get(TYPES.Sync_Timer)))
@@ -217,7 +223,12 @@ export class ContainerConfigLoader {
       .toConstantValue(new SavedItemHttpMapper(container.get(TYPES.Sync_Timer)))
     container
       .bind<MapperInterface<ItemConflict, ItemConflictHttpRepresentation>>(TYPES.Sync_ItemConflictHttpMapper)
-      .toConstantValue(new ItemConflictHttpMapper(container.get(TYPES.Sync_ItemHttpMapper)))
+      .toConstantValue(
+        new ItemConflictHttpMapper(
+          container.get(TYPES.Sync_ItemHttpMapper),
+          container.get(TYPES.Sync_ItemHashHttpMapper),
+        ),
+      )
     container
       .bind<MapperInterface<Item, ItemBackupRepresentation>>(TYPES.Sync_ItemBackupMapper)
       .toConstantValue(new ItemBackupMapper(container.get(TYPES.Sync_Timer)))
@@ -282,16 +293,35 @@ export class ContainerConfigLoader {
         env.get('MAX_ITEMS_LIMIT', true) ? +env.get('MAX_ITEMS_LIMIT', true) : this.DEFAULT_MAX_ITEMS_LIMIT,
       )
 
+    container.bind<OwnershipFilter>(TYPES.Sync_OwnershipFilter).toConstantValue(new OwnershipFilter())
+    container
+      .bind<TimeDifferenceFilter>(TYPES.Sync_TimeDifferenceFilter)
+      .toConstantValue(new TimeDifferenceFilter(container.get(TYPES.Sync_Timer)))
+    container.bind<ContentTypeFilter>(TYPES.Sync_ContentTypeFilter).toConstantValue(new ContentTypeFilter())
+    container.bind<ContentFilter>(TYPES.Sync_ContentFilter).toConstantValue(new ContentFilter())
+    container
+      .bind<ItemSaveValidatorInterface>(TYPES.Sync_ItemSaveValidator)
+      .toConstantValue(
+        new ItemSaveValidator([
+          container.get(TYPES.Sync_OwnershipFilter),
+          container.get(TYPES.Sync_TimeDifferenceFilter),
+          container.get(TYPES.Sync_ContentTypeFilter),
+          container.get(TYPES.Sync_ContentFilter),
+        ]),
+      )
+
     // use cases
-    container.bind<SyncItems>(TYPES.Sync_SyncItems).toDynamicValue((context: interfaces.Context) => {
-      return new SyncItems(context.container.get(TYPES.Sync_ItemService))
-    })
-    container.bind<CheckIntegrity>(TYPES.Sync_CheckIntegrity).toDynamicValue((context: interfaces.Context) => {
-      return new CheckIntegrity(context.container.get(TYPES.Sync_ItemRepository))
-    })
-    container.bind<GetItem>(TYPES.Sync_GetItem).toDynamicValue((context: interfaces.Context) => {
-      return new GetItem(context.container.get(TYPES.Sync_ItemRepository))
-    })
+    container
+      .bind<GetItems>(TYPES.Sync_GetItems)
+      .toConstantValue(
+        new GetItems(
+          container.get(TYPES.Sync_ItemRepository),
+          container.get(TYPES.Sync_CONTENT_SIZE_TRANSFER_LIMIT),
+          container.get(TYPES.Sync_ItemTransferCalculator),
+          container.get(TYPES.Sync_Timer),
+          container.get(TYPES.Sync_MAX_ITEMS_LIMIT),
+        ),
+      )
     container
       .bind<SaveNewItem>(TYPES.Sync_SaveNewItem)
       .toConstantValue(
@@ -313,41 +343,35 @@ export class ContainerConfigLoader {
           container.get(TYPES.Sync_REVISIONS_FREQUENCY),
         ),
       )
-
-    // Services
-    container.bind<OwnershipFilter>(TYPES.Sync_OwnershipFilter).toConstantValue(new OwnershipFilter())
-    container
-      .bind<TimeDifferenceFilter>(TYPES.Sync_TimeDifferenceFilter)
-      .toConstantValue(new TimeDifferenceFilter(container.get(TYPES.Sync_Timer)))
-    container.bind<ContentTypeFilter>(TYPES.Sync_ContentTypeFilter).toConstantValue(new ContentTypeFilter())
-    container.bind<ContentFilter>(TYPES.Sync_ContentFilter).toConstantValue(new ContentFilter())
-
-    container
-      .bind<ItemSaveValidatorInterface>(TYPES.Sync_ItemSaveValidator)
-      .toDynamicValue((context: interfaces.Context) => {
-        return new ItemSaveValidator([
-          context.container.get(TYPES.Sync_OwnershipFilter),
-          context.container.get(TYPES.Sync_TimeDifferenceFilter),
-          context.container.get(TYPES.Sync_ContentTypeFilter),
-          context.container.get(TYPES.Sync_ContentFilter),
-        ])
-      })
-
     container
-      .bind<ItemServiceInterface>(TYPES.Sync_ItemService)
+      .bind<SaveItems>(TYPES.Sync_SaveItems)
       .toConstantValue(
-        new ItemService(
+        new SaveItems(
           container.get(TYPES.Sync_ItemSaveValidator),
           container.get(TYPES.Sync_ItemRepository),
-          container.get(TYPES.Sync_CONTENT_SIZE_TRANSFER_LIMIT),
-          container.get(TYPES.Sync_ItemTransferCalculator),
           container.get(TYPES.Sync_Timer),
-          container.get(TYPES.Sync_MAX_ITEMS_LIMIT),
           container.get(TYPES.Sync_SaveNewItem),
           container.get(TYPES.Sync_UpdateExistingItem),
           container.get(TYPES.Sync_Logger),
         ),
       )
+    container
+      .bind<SyncItems>(TYPES.Sync_SyncItems)
+      .toConstantValue(
+        new SyncItems(
+          container.get(TYPES.Sync_ItemRepository),
+          container.get(TYPES.Sync_GetItems),
+          container.get(TYPES.Sync_SaveItems),
+        ),
+      )
+    container.bind<CheckIntegrity>(TYPES.Sync_CheckIntegrity).toDynamicValue((context: interfaces.Context) => {
+      return new CheckIntegrity(context.container.get(TYPES.Sync_ItemRepository))
+    })
+    container.bind<GetItem>(TYPES.Sync_GetItem).toDynamicValue((context: interfaces.Context) => {
+      return new GetItem(context.container.get(TYPES.Sync_ItemRepository))
+    })
+
+    // Services
     container
       .bind<SyncResponseFactory20161215>(TYPES.Sync_SyncResponseFactory20161215)
       .toConstantValue(new SyncResponseFactory20161215(container.get(TYPES.Sync_ItemHttpMapper)))

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

@@ -55,6 +55,8 @@ const TYPES = {
   Sync_DeleteMessage: Symbol.for('Sync_DeleteMessage'),
   Sync_SaveNewItem: Symbol.for('Sync_SaveNewItem'),
   Sync_UpdateExistingItem: Symbol.for('Sync_UpdateExistingItem'),
+  Sync_GetItems: Symbol.for('Sync_GetItems'),
+  Sync_SaveItems: Symbol.for('Sync_SaveItems'),
   // Handlers
   Sync_AccountDeletionRequestedEventHandler: Symbol.for('Sync_AccountDeletionRequestedEventHandler'),
   Sync_DuplicateItemSyncedEventHandler: Symbol.for('Sync_DuplicateItemSyncedEventHandler'),
@@ -67,7 +69,6 @@ const TYPES = {
   Sync_DomainEventFactory: Symbol.for('Sync_DomainEventFactory'),
   Sync_DomainEventMessageHandler: Symbol.for('Sync_DomainEventMessageHandler'),
   Sync_HTTPClient: Symbol.for('Sync_HTTPClient'),
-  Sync_ItemService: Symbol.for('Sync_ItemService'),
   Sync_Timer: Symbol.for('Sync_Timer'),
   Sync_SyncResponseFactory20161215: Symbol.for('Sync_SyncResponseFactory20161215'),
   Sync_SyncResponseFactory20200115: Symbol.for('Sync_SyncResponseFactory20200115'),
@@ -90,6 +91,7 @@ const TYPES = {
   Sync_MessageHttpMapper: Symbol.for('Sync_MessageHttpMapper'),
   Sync_ItemPersistenceMapper: Symbol.for('Sync_ItemPersistenceMapper'),
   Sync_ItemHttpMapper: Symbol.for('Sync_ItemHttpMapper'),
+  Sync_ItemHashHttpMapper: Symbol.for('Sync_ItemHashHttpMapper'),
   Sync_SavedItemHttpMapper: Symbol.for('Sync_SavedItemHttpMapper'),
   Sync_ItemConflictHttpMapper: Symbol.for('Sync_ItemConflictHttpMapper'),
   Sync_ItemBackupMapper: Symbol.for('Sync_ItemBackupMapper'),

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

@@ -1,6 +0,0 @@
-import { Item } from './Item'
-
-export type GetItemsResult = {
-  items: Array<Item>
-  cursorToken?: string
-}

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

@@ -3,10 +3,6 @@ import { Entity, Result, UniqueEntityId } from '@standardnotes/domain-core'
 import { ItemProps } from './ItemProps'
 
 export class Item extends Entity<ItemProps> {
-  get id(): UniqueEntityId {
-    return this._id
-  }
-
   private constructor(props: ItemProps, id?: UniqueEntityId) {
     super(props, id)
   }

+ 12 - 13
packages/syncing-server/src/Domain/Item/ItemHash.ts

@@ -1,14 +1,13 @@
-export type ItemHash = {
-  uuid: string
-  content?: string
-  content_type: string | null
-  deleted?: boolean
-  duplicate_of?: string | null
-  auth_hash?: string
-  enc_item_key?: string
-  items_key_id?: string
-  created_at?: string
-  created_at_timestamp?: number
-  updated_at?: string
-  updated_at_timestamp?: number
+import { Result, ValueObject } from '@standardnotes/domain-core'
+
+import { ItemHashProps } from './ItemHashProps'
+
+export class ItemHash extends ValueObject<ItemHashProps> {
+  private constructor(props: ItemHashProps) {
+    super(props)
+  }
+
+  static create(props: ItemHashProps): Result<ItemHash> {
+    return Result.ok<ItemHash>(new ItemHash(props))
+  }
 }

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

@@ -0,0 +1,17 @@
+export interface ItemHashProps {
+  uuid: string
+  user_uuid: string
+  content?: string
+  content_type: string | null
+  deleted?: boolean
+  duplicate_of?: string | null
+  auth_hash?: string
+  enc_item_key?: string
+  items_key_id?: string
+  key_system_identifier: string | null
+  shared_vault_uuid: string | null
+  created_at?: string
+  created_at_timestamp?: number
+  updated_at?: string
+  updated_at_timestamp?: number
+}

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

@@ -1,785 +0,0 @@
-import 'reflect-metadata'
-
-import { Timer, TimerInterface } from '@standardnotes/time'
-import { Logger } from 'winston'
-
-import { Item } from './Item'
-import { ItemHash } from './ItemHash'
-
-import { ItemRepositoryInterface } from './ItemRepositoryInterface'
-import { ItemService } from './ItemService'
-import { ApiVersion } from '../Api/ApiVersion'
-import { ItemSaveValidatorInterface } from './SaveValidator/ItemSaveValidatorInterface'
-import { ItemConflict } from './ItemConflict'
-import { ItemTransferCalculatorInterface } from './ItemTransferCalculatorInterface'
-import { SaveNewItem } from '../UseCase/Syncing/SaveNewItem/SaveNewItem'
-import { UpdateExistingItem } from '../UseCase/Syncing/UpdateExistingItem/UpdateExistingItem'
-import { Uuid, ContentType, Dates, Timestamps, UniqueEntityId, Result } from '@standardnotes/domain-core'
-
-describe('ItemService', () => {
-  let itemRepository: ItemRepositoryInterface
-  const contentSizeTransferLimit = 100
-  let timer: TimerInterface
-  let item1: Item
-  let item2: Item
-  let itemHash1: ItemHash
-  let itemHash2: ItemHash
-  let syncToken: string
-  let logger: Logger
-  let itemSaveValidator: ItemSaveValidatorInterface
-  let newItem: Item
-  let timeHelper: Timer
-  let itemTransferCalculator: ItemTransferCalculatorInterface
-  let saveNewItemUseCase: SaveNewItem
-  let updateExistingItemUseCase: UpdateExistingItem
-  const maxItemsSyncLimit = 300
-
-  const createService = () =>
-    new ItemService(
-      itemSaveValidator,
-      itemRepository,
-      contentSizeTransferLimit,
-      itemTransferCalculator,
-      timer,
-      maxItemsSyncLimit,
-      saveNewItemUseCase,
-      updateExistingItemUseCase,
-      logger,
-    )
-
-  beforeEach(() => {
-    timeHelper = new Timer()
-
-    item1 = Item.create(
-      {
-        userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
-        updatedWithSession: null,
-        content: 'foobar1',
-        contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
-        encItemKey: null,
-        authHash: null,
-        itemsKeyId: null,
-        duplicateOf: null,
-        deleted: false,
-        dates: Dates.create(new Date(1616164633241311), new Date(1616164633241311)).getValue(),
-        timestamps: Timestamps.create(1616164633241311, 1616164633241311).getValue(),
-      },
-      new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
-    ).getValue()
-    item2 = Item.create(
-      {
-        userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
-        updatedWithSession: null,
-        content: 'foobar2',
-        contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
-        encItemKey: null,
-        authHash: null,
-        itemsKeyId: null,
-        duplicateOf: null,
-        deleted: false,
-        dates: Dates.create(new Date(1616164633241312), new Date(1616164633241312)).getValue(),
-        timestamps: Timestamps.create(1616164633241312, 1616164633241312).getValue(),
-      },
-      new UniqueEntityId('00000000-0000-0000-0000-000000000001'),
-    ).getValue()
-
-    itemHash1 = {
-      uuid: '1-2-3',
-      content: 'asdqwe1',
-      content_type: ContentType.TYPES.Note,
-      duplicate_of: null,
-      enc_item_key: 'qweqwe1',
-      items_key_id: 'asdasd1',
-      created_at: timeHelper.formatDate(
-        timeHelper.convertMicrosecondsToDate(item1.props.timestamps.createdAt),
-        'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
-      ),
-      updated_at: timeHelper.formatDate(
-        new Date(timeHelper.convertMicrosecondsToMilliseconds(item1.props.timestamps.updatedAt) + 1),
-        'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
-      ),
-    } as jest.Mocked<ItemHash>
-
-    itemHash2 = {
-      uuid: '2-3-4',
-      content: 'asdqwe2',
-      content_type: ContentType.TYPES.Note,
-      duplicate_of: null,
-      enc_item_key: 'qweqwe2',
-      items_key_id: 'asdasd2',
-      created_at: timeHelper.formatDate(
-        timeHelper.convertMicrosecondsToDate(item2.props.timestamps.createdAt),
-        'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
-      ),
-      updated_at: timeHelper.formatDate(
-        new Date(timeHelper.convertMicrosecondsToMilliseconds(item2.props.timestamps.updatedAt) + 1),
-        'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
-      ),
-    } as jest.Mocked<ItemHash>
-
-    itemTransferCalculator = {} as jest.Mocked<ItemTransferCalculatorInterface>
-    itemTransferCalculator.computeItemUuidsToFetch = jest
-      .fn()
-      .mockReturnValue([item1.id.toString(), item2.id.toString()])
-
-    itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
-    itemRepository.findAll = jest.fn().mockReturnValue([item1, item2])
-    itemRepository.countAll = jest.fn().mockReturnValue(2)
-
-    timer = {} as jest.Mocked<TimerInterface>
-    timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(1616164633241568)
-    timer.getUTCDate = jest.fn().mockReturnValue(new Date())
-    timer.convertStringDateToDate = jest
-      .fn()
-      .mockImplementation((date: string) => timeHelper.convertStringDateToDate(date))
-    timer.convertMicrosecondsToSeconds = jest.fn().mockReturnValue(600)
-    timer.convertStringDateToMicroseconds = jest
-      .fn()
-      .mockImplementation((date: string) => timeHelper.convertStringDateToMicroseconds(date))
-    timer.convertMicrosecondsToDate = jest
-      .fn()
-      .mockImplementation((microseconds: number) => timeHelper.convertMicrosecondsToDate(microseconds))
-
-    logger = {} as jest.Mocked<Logger>
-    logger.error = jest.fn()
-    logger.warn = jest.fn()
-
-    syncToken = Buffer.from('2:1616164633.241564', 'utf-8').toString('base64')
-
-    itemSaveValidator = {} as jest.Mocked<ItemSaveValidatorInterface>
-    itemSaveValidator.validate = jest.fn().mockReturnValue({ passed: true })
-
-    newItem = Item.create(
-      {
-        userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
-        updatedWithSession: null,
-        content: 'foobar2',
-        contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
-        encItemKey: null,
-        authHash: null,
-        itemsKeyId: null,
-        duplicateOf: null,
-        deleted: false,
-        dates: Dates.create(new Date(1616164633241313), new Date(1616164633241313)).getValue(),
-        timestamps: Timestamps.create(1616164633241313, 1616164633241313).getValue(),
-      },
-      new UniqueEntityId('00000000-0000-0000-0000-000000000002'),
-    ).getValue()
-
-    saveNewItemUseCase = {} as jest.Mocked<SaveNewItem>
-    saveNewItemUseCase.execute = jest.fn().mockReturnValue(Result.ok(newItem))
-
-    updateExistingItemUseCase = {} as jest.Mocked<UpdateExistingItem>
-    updateExistingItemUseCase.execute = jest.fn().mockReturnValue(Result.ok(item1))
-  })
-
-  it('should retrieve all items for a user from last sync with sync token version 1', async () => {
-    syncToken = Buffer.from('1:2021-03-15 07:00:00', 'utf-8').toString('base64')
-
-    expect(
-      await createService().getItems({
-        userUuid: '1-2-3',
-        syncToken,
-        contentType: ContentType.TYPES.Note,
-      }),
-    ).toEqual({
-      items: [item1, item2],
-    })
-
-    expect(itemRepository.countAll).toHaveBeenCalledWith({
-      contentType: 'Note',
-      lastSyncTime: 1615791600000000,
-      syncTimeComparison: '>',
-      sortBy: 'updated_at_timestamp',
-      sortOrder: 'ASC',
-      userUuid: '1-2-3',
-      limit: 150,
-    })
-    expect(itemRepository.findAll).toHaveBeenCalledWith({
-      uuids: ['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'],
-      sortOrder: 'ASC',
-      sortBy: 'updated_at_timestamp',
-    })
-  })
-
-  it('should retrieve all items for a user from last sync', async () => {
-    expect(
-      await createService().getItems({
-        userUuid: '1-2-3',
-        syncToken,
-        contentType: ContentType.TYPES.Note,
-      }),
-    ).toEqual({
-      items: [item1, item2],
-    })
-
-    expect(itemRepository.countAll).toHaveBeenCalledWith({
-      contentType: 'Note',
-      lastSyncTime: 1616164633241564,
-      syncTimeComparison: '>',
-      sortBy: 'updated_at_timestamp',
-      sortOrder: 'ASC',
-      userUuid: '1-2-3',
-      limit: 150,
-    })
-    expect(itemRepository.findAll).toHaveBeenCalledWith({
-      uuids: ['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'],
-      sortBy: 'updated_at_timestamp',
-      sortOrder: 'ASC',
-    })
-  })
-
-  it('should retrieve all items for a user from last sync with upper bound items limit', async () => {
-    expect(
-      await createService().getItems({
-        userUuid: '1-2-3',
-        syncToken,
-        contentType: ContentType.TYPES.Note,
-        limit: 1000,
-      }),
-    ).toEqual({
-      items: [item1, item2],
-    })
-
-    expect(itemRepository.countAll).toHaveBeenCalledWith({
-      contentType: 'Note',
-      lastSyncTime: 1616164633241564,
-      syncTimeComparison: '>',
-      sortBy: 'updated_at_timestamp',
-      sortOrder: 'ASC',
-      userUuid: '1-2-3',
-      limit: 300,
-    })
-    expect(itemRepository.findAll).toHaveBeenCalledWith({
-      uuids: ['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'],
-      sortBy: 'updated_at_timestamp',
-      sortOrder: 'ASC',
-    })
-  })
-
-  it('should retrieve no items for a user if there are none from last sync', async () => {
-    itemTransferCalculator.computeItemUuidsToFetch = jest.fn().mockReturnValue([])
-
-    expect(
-      await createService().getItems({
-        userUuid: '1-2-3',
-        syncToken,
-        contentType: ContentType.TYPES.Note,
-      }),
-    ).toEqual({
-      items: [],
-    })
-
-    expect(itemRepository.findAll).not.toHaveBeenCalled()
-    expect(itemRepository.countAll).toHaveBeenCalledWith({
-      contentType: 'Note',
-      lastSyncTime: 1616164633241564,
-      syncTimeComparison: '>',
-      sortBy: 'updated_at_timestamp',
-      sortOrder: 'ASC',
-      userUuid: '1-2-3',
-      limit: 150,
-    })
-  })
-
-  it('should return a cursor token if there are more items than requested with limit', async () => {
-    itemRepository.findAll = jest.fn().mockReturnValue([item1])
-
-    const itemsResponse = await createService().getItems({
-      userUuid: '1-2-3',
-      syncToken,
-      limit: 1,
-      contentType: ContentType.TYPES.Note,
-    })
-
-    expect(itemsResponse).toEqual({
-      cursorToken: 'MjoxNjE2MTY0NjMzLjI0MTMxMQ==',
-      items: [item1],
-    })
-
-    expect(Buffer.from(<string>itemsResponse.cursorToken, 'base64').toString('utf-8')).toEqual('2:1616164633.241311')
-
-    expect(itemRepository.countAll).toHaveBeenCalledWith({
-      contentType: 'Note',
-      lastSyncTime: 1616164633241564,
-      syncTimeComparison: '>',
-      sortBy: 'updated_at_timestamp',
-      sortOrder: 'ASC',
-      userUuid: '1-2-3',
-      limit: 1,
-    })
-    expect(itemRepository.findAll).toHaveBeenCalledWith({
-      uuids: ['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'],
-      sortBy: 'updated_at_timestamp',
-      sortOrder: 'ASC',
-    })
-  })
-
-  it('should retrieve all items for a user from cursor token', async () => {
-    const cursorToken = Buffer.from('2:1616164633.241123', 'utf-8').toString('base64')
-
-    expect(
-      await createService().getItems({
-        userUuid: '1-2-3',
-        syncToken,
-        cursorToken,
-        contentType: ContentType.TYPES.Note,
-      }),
-    ).toEqual({
-      items: [item1, item2],
-    })
-
-    expect(itemRepository.countAll).toHaveBeenCalledWith({
-      contentType: 'Note',
-      lastSyncTime: 1616164633241123,
-      syncTimeComparison: '>=',
-      sortBy: 'updated_at_timestamp',
-      sortOrder: 'ASC',
-      userUuid: '1-2-3',
-      limit: 150,
-    })
-    expect(itemRepository.findAll).toHaveBeenCalledWith({
-      uuids: ['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'],
-      sortBy: 'updated_at_timestamp',
-      sortOrder: 'ASC',
-    })
-  })
-
-  it('should retrieve all undeleted items for a user without cursor or sync token', async () => {
-    expect(
-      await createService().getItems({
-        userUuid: '1-2-3',
-        contentType: ContentType.TYPES.Note,
-      }),
-    ).toEqual({
-      items: [item1, item2],
-    })
-
-    expect(itemRepository.countAll).toHaveBeenCalledWith({
-      contentType: 'Note',
-      deleted: false,
-      sortBy: 'updated_at_timestamp',
-      sortOrder: 'ASC',
-      syncTimeComparison: '>',
-      userUuid: '1-2-3',
-      limit: 150,
-    })
-    expect(itemRepository.findAll).toHaveBeenCalledWith({
-      uuids: ['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'],
-      sortBy: 'updated_at_timestamp',
-      sortOrder: 'ASC',
-    })
-  })
-
-  it('should retrieve all items with default limit if not defined', async () => {
-    await createService().getItems({
-      userUuid: '1-2-3',
-      syncToken,
-      contentType: ContentType.TYPES.Note,
-    })
-
-    expect(itemRepository.countAll).toHaveBeenCalledWith({
-      contentType: 'Note',
-      lastSyncTime: 1616164633241564,
-      syncTimeComparison: '>',
-      sortBy: 'updated_at_timestamp',
-      sortOrder: 'ASC',
-      userUuid: '1-2-3',
-      limit: 150,
-    })
-    expect(itemRepository.findAll).toHaveBeenCalledWith({
-      uuids: ['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'],
-      sortOrder: 'ASC',
-      sortBy: 'updated_at_timestamp',
-    })
-  })
-
-  it('should retrieve all items with non-positive limit if not defined', async () => {
-    await createService().getItems({
-      userUuid: '1-2-3',
-      syncToken,
-      limit: 0,
-      contentType: ContentType.TYPES.Note,
-    })
-
-    expect(itemRepository.countAll).toHaveBeenCalledWith({
-      contentType: 'Note',
-      lastSyncTime: 1616164633241564,
-      syncTimeComparison: '>',
-      sortBy: 'updated_at_timestamp',
-      sortOrder: 'ASC',
-      userUuid: '1-2-3',
-      limit: 150,
-    })
-    expect(itemRepository.findAll).toHaveBeenCalledWith({
-      uuids: ['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'],
-      sortBy: 'updated_at_timestamp',
-      sortOrder: 'ASC',
-    })
-  })
-
-  it('should throw an error if the sync token is missing time', async () => {
-    let error = null
-
-    try {
-      await createService().getItems({
-        userUuid: '1-2-3',
-        syncToken: '2:',
-        limit: 0,
-        contentType: ContentType.TYPES.Note,
-      })
-    } catch (e) {
-      error = e
-    }
-
-    expect(error).not.toBeNull()
-  })
-
-  it('should throw an error if the sync token is missing version', async () => {
-    let error = null
-
-    try {
-      await createService().getItems({
-        userUuid: '1-2-3',
-        syncToken: '1234567890',
-        limit: 0,
-        contentType: ContentType.TYPES.Note,
-      })
-    } catch (e) {
-      error = e
-    }
-
-    expect(error).not.toBeNull()
-  })
-
-  it('should front load keys items to top of the collection for better client performance', async () => {
-    const item3 = Item.create(
-      {
-        userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
-        updatedWithSession: null,
-        content: 'foobar1',
-        contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
-        encItemKey: null,
-        authHash: null,
-        itemsKeyId: null,
-        duplicateOf: null,
-        deleted: false,
-        dates: Dates.create(new Date(1616164633241311), new Date(1616164633241311)).getValue(),
-        timestamps: Timestamps.create(1616164633241311, 1616164633241311).getValue(),
-      },
-      new UniqueEntityId('00000000-0000-0000-0000-000000000003'),
-    ).getValue()
-    const item4 = Item.create(
-      {
-        userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
-        updatedWithSession: null,
-        content: 'foobar2',
-        contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
-        encItemKey: null,
-        authHash: null,
-        itemsKeyId: null,
-        duplicateOf: null,
-        deleted: false,
-        dates: Dates.create(new Date(1616164633241312), new Date(1616164633241312)).getValue(),
-        timestamps: Timestamps.create(1616164633241312, 1616164633241312).getValue(),
-      },
-      new UniqueEntityId('00000000-0000-0000-0000-000000000004'),
-    ).getValue()
-
-    itemRepository.findAll = jest.fn().mockReturnValue([item3, item4])
-
-    await createService().frontLoadKeysItemsToTop('1-2-3', [item1, item2])
-  })
-
-  it('should save new items', async () => {
-    itemRepository.findByUuid = jest.fn().mockReturnValue(null)
-
-    const result = await createService().saveItems({
-      itemHashes: [itemHash1],
-      userUuid: '1-2-3',
-      apiVersion: ApiVersion.v20200115,
-      readOnlyAccess: false,
-      sessionUuid: '2-3-4',
-    })
-
-    expect(result).toEqual({
-      conflicts: [],
-      savedItems: [newItem],
-      syncToken: 'MjoxNjE2MTY0NjMzLjI0MTMxNA==',
-    })
-
-    expect(saveNewItemUseCase.execute).toHaveBeenCalled()
-  })
-
-  it('should not save new items in read only access mode', async () => {
-    itemRepository.findByUuid = jest.fn().mockReturnValue(null)
-
-    const result = await createService().saveItems({
-      itemHashes: [itemHash1],
-      userUuid: '1-2-3',
-      apiVersion: ApiVersion.v20200115,
-      readOnlyAccess: true,
-      sessionUuid: null,
-    })
-
-    expect(result).toEqual({
-      conflicts: [
-        {
-          type: 'readonly_error',
-          unsavedItem: itemHash1,
-        },
-      ],
-      savedItems: [],
-      syncToken: 'MjoxNjE2MTY0NjMzLjI0MTU2OQ==',
-    })
-
-    expect(saveNewItemUseCase.execute).not.toHaveBeenCalled()
-  })
-
-  it('should save new items that are duplicates', async () => {
-    itemRepository.findByUuid = jest.fn().mockReturnValue(null)
-    const duplicateItem = Item.create(
-      {
-        userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
-        updatedWithSession: null,
-        content: 'foobar1',
-        contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
-        encItemKey: null,
-        authHash: null,
-        itemsKeyId: null,
-        duplicateOf: Uuid.create('00000000-0000-0000-0000-000000000001').getValue(),
-        deleted: false,
-        dates: Dates.create(new Date(1616164633241570), new Date(1616164633241570)).getValue(),
-        timestamps: Timestamps.create(1616164633241570, 1616164633241570).getValue(),
-      },
-      new UniqueEntityId('00000000-0000-0000-0000-000000000005'),
-    ).getValue()
-    saveNewItemUseCase.execute = jest.fn().mockReturnValue(Result.ok(duplicateItem))
-
-    const result = await createService().saveItems({
-      itemHashes: [itemHash1],
-      userUuid: '1-2-3',
-      apiVersion: ApiVersion.v20200115,
-      readOnlyAccess: false,
-      sessionUuid: '2-3-4',
-    })
-
-    expect(result).toEqual({
-      conflicts: [],
-      savedItems: [duplicateItem],
-      syncToken: 'MjoxNjE2MTY0NjMzLjI0MTU3MQ==',
-    })
-  })
-
-  it('should skip items that are conflicting on validation', async () => {
-    itemRepository.findByUuid = jest.fn().mockReturnValue(null)
-
-    const conflict = {} as jest.Mocked<ItemConflict>
-    const validationResult = { passed: false, conflict }
-    itemSaveValidator.validate = jest.fn().mockReturnValue(validationResult)
-
-    const result = await createService().saveItems({
-      itemHashes: [itemHash1],
-      userUuid: '1-2-3',
-      apiVersion: ApiVersion.v20200115,
-      readOnlyAccess: false,
-      sessionUuid: '2-3-4',
-    })
-
-    expect(result).toEqual({
-      conflicts: [conflict],
-      savedItems: [],
-      syncToken: 'MjoxNjE2MTY0NjMzLjI0MTU2OQ==',
-    })
-  })
-
-  it('should mark items as saved that are skipped on validation', async () => {
-    itemRepository.findByUuid = jest.fn().mockReturnValue(null)
-
-    const skipped = item1
-    const validationResult = { passed: false, skipped }
-    itemSaveValidator.validate = jest.fn().mockReturnValue(validationResult)
-
-    const result = await createService().saveItems({
-      itemHashes: [itemHash1],
-      userUuid: '1-2-3',
-      apiVersion: ApiVersion.v20200115,
-      readOnlyAccess: false,
-      sessionUuid: '2-3-4',
-    })
-
-    expect(result).toEqual({
-      conflicts: [],
-      savedItems: [skipped],
-      syncToken: 'MjoxNjE2MTY0NjMzLjI0MTMxMg==',
-    })
-  })
-
-  it('should calculate the sync token based on last updated date of saved items incremented with 1 microsecond to avoid returning same object in subsequent sync', async () => {
-    itemRepository.findByUuid = jest.fn().mockReturnValue(null)
-
-    const itemHash3 = {
-      uuid: '3-4-5',
-      content: 'asdqwe3',
-      content_type: ContentType.TYPES.Note,
-      duplicate_of: null,
-      enc_item_key: 'qweqwe3',
-      items_key_id: 'asdasd3',
-      created_at: '2021-02-19T11:35:45.652Z',
-      updated_at: '2021-03-25T09:37:37.943Z',
-    } as jest.Mocked<ItemHash>
-
-    const saveProcedureStartTimestamp = 1616164633241580
-    const item1Timestamp = 1616164633241570
-    const item2Timestamp = 1616164633241568
-    const item3Timestamp = 1616164633241569
-    timer.getTimestampInMicroseconds = jest.fn().mockReturnValueOnce(saveProcedureStartTimestamp)
-
-    saveNewItemUseCase.execute = jest
-      .fn()
-      .mockReturnValueOnce(
-        Result.ok(
-          Item.create(
-            {
-              ...item1.props,
-              timestamps: Timestamps.create(item1Timestamp, item1Timestamp).getValue(),
-            },
-            new UniqueEntityId('00000000-0000-0000-0000-000000000001'),
-          ).getValue(),
-        ),
-      )
-      .mockReturnValueOnce(
-        Result.ok(
-          Item.create(
-            {
-              ...item2.props,
-              timestamps: Timestamps.create(item2Timestamp, item2Timestamp).getValue(),
-            },
-            new UniqueEntityId('00000000-0000-0000-0000-000000000002'),
-          ).getValue(),
-        ),
-      )
-      .mockReturnValueOnce(
-        Result.ok(
-          Item.create(
-            {
-              ...item2.props,
-              timestamps: Timestamps.create(item3Timestamp, item3Timestamp).getValue(),
-            },
-            new UniqueEntityId('00000000-0000-0000-0000-000000000003'),
-          ).getValue(),
-        ),
-      )
-
-    const result = await createService().saveItems({
-      itemHashes: [itemHash1, itemHash3, itemHash2],
-      userUuid: '1-2-3',
-      apiVersion: ApiVersion.v20200115,
-      readOnlyAccess: false,
-      sessionUuid: '2-3-4',
-    })
-
-    expect(result.syncToken).toEqual('MjoxNjE2MTY0NjMzLjI0MTU3MQ==')
-    expect(Buffer.from(result.syncToken, 'base64').toString('utf-8')).toEqual('2:1616164633.241571')
-  })
-
-  it('should update existing items', async () => {
-    itemRepository.findByUuid = jest.fn().mockReturnValue(item1)
-
-    const result = await createService().saveItems({
-      itemHashes: [itemHash1],
-      userUuid: '1-2-3',
-      apiVersion: ApiVersion.v20200115,
-      readOnlyAccess: false,
-      sessionUuid: '2-3-4',
-    })
-
-    expect(result).toEqual({
-      conflicts: [],
-      savedItems: [item1],
-      syncToken: 'MjoxNjE2MTY0NjMzLjI0MTMxMg==',
-    })
-  })
-
-  it('should mark as skipped existing items that failed to update', async () => {
-    itemRepository.findByUuid = jest.fn().mockReturnValue(item1)
-    updateExistingItemUseCase.execute = jest.fn().mockReturnValue(Result.fail('Oops'))
-
-    const result = await createService().saveItems({
-      itemHashes: [itemHash1],
-      userUuid: '1-2-3',
-      apiVersion: ApiVersion.v20200115,
-      readOnlyAccess: false,
-      sessionUuid: '2-3-4',
-    })
-
-    expect(result).toEqual({
-      conflicts: [
-        {
-          type: 'uuid_conflict',
-          unsavedItem: itemHash1,
-        },
-      ],
-      savedItems: [],
-      syncToken: 'MjoxNjE2MTY0NjMzLjI0MTU2OQ==',
-    })
-  })
-
-  it('should skip saving conflicting items and mark them as sync conflicts when saving fails', async () => {
-    itemRepository.findByUuid = jest.fn().mockReturnValue(null)
-    saveNewItemUseCase.execute = jest.fn().mockReturnValue(Result.fail('Oops'))
-
-    const result = await createService().saveItems({
-      itemHashes: [itemHash1, itemHash2],
-      userUuid: '1-2-3',
-      apiVersion: ApiVersion.v20200115,
-      readOnlyAccess: false,
-      sessionUuid: '2-3-4',
-    })
-
-    expect(result).toEqual({
-      conflicts: [
-        {
-          type: 'uuid_conflict',
-          unsavedItem: itemHash1,
-        },
-        {
-          type: 'uuid_conflict',
-          unsavedItem: itemHash2,
-        },
-      ],
-      savedItems: [],
-      syncToken: 'MjoxNjE2MTY0NjMzLjI0MTU2OQ==',
-    })
-  })
-
-  it('should skip saving conflicting items and mark them as sync conflicts when saving throws an error', async () => {
-    itemRepository.findByUuid = jest.fn().mockReturnValue(null)
-    saveNewItemUseCase.execute = jest.fn().mockImplementation(() => {
-      throw new Error('Oops')
-    })
-
-    const result = await createService().saveItems({
-      itemHashes: [itemHash1, itemHash2],
-      userUuid: '1-2-3',
-      apiVersion: ApiVersion.v20200115,
-      readOnlyAccess: false,
-      sessionUuid: '2-3-4',
-    })
-
-    expect(result).toEqual({
-      conflicts: [
-        {
-          type: 'uuid_conflict',
-          unsavedItem: itemHash1,
-        },
-        {
-          type: 'uuid_conflict',
-          unsavedItem: itemHash2,
-        },
-      ],
-      savedItems: [],
-      syncToken: 'MjoxNjE2MTY0NjMzLjI0MTU2OQ==',
-    })
-  })
-})

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

@@ -1,239 +0,0 @@
-import { Time, TimerInterface } from '@standardnotes/time'
-import { Logger } from 'winston'
-
-import { GetItemsDTO } from './GetItemsDTO'
-import { GetItemsResult } from './GetItemsResult'
-import { Item } from './Item'
-import { ItemConflict } from './ItemConflict'
-import { ItemQuery } from './ItemQuery'
-import { ItemRepositoryInterface } from './ItemRepositoryInterface'
-import { ItemServiceInterface } from './ItemServiceInterface'
-import { SaveItemsDTO } from './SaveItemsDTO'
-import { SaveItemsResult } from './SaveItemsResult'
-import { ItemSaveValidatorInterface } from './SaveValidator/ItemSaveValidatorInterface'
-import { ConflictType } from '@standardnotes/responses'
-import { ItemTransferCalculatorInterface } from './ItemTransferCalculatorInterface'
-import { SaveNewItem } from '../UseCase/Syncing/SaveNewItem/SaveNewItem'
-import { ContentType } from '@standardnotes/domain-core'
-import { UpdateExistingItem } from '../UseCase/Syncing/UpdateExistingItem/UpdateExistingItem'
-
-export class ItemService implements ItemServiceInterface {
-  private readonly DEFAULT_ITEMS_LIMIT = 150
-  private readonly SYNC_TOKEN_VERSION = 2
-
-  constructor(
-    private itemSaveValidator: ItemSaveValidatorInterface,
-    private itemRepository: ItemRepositoryInterface,
-    private contentSizeTransferLimit: number,
-    private itemTransferCalculator: ItemTransferCalculatorInterface,
-    private timer: TimerInterface,
-    private maxItemsSyncLimit: number,
-    private saveNewItem: SaveNewItem,
-    private updateExistingItem: UpdateExistingItem,
-    private logger: Logger,
-  ) {}
-
-  async getItems(dto: GetItemsDTO): Promise<GetItemsResult> {
-    const lastSyncTime = this.getLastSyncTime(dto)
-    const syncTimeComparison = dto.cursorToken ? '>=' : '>'
-    const limit = dto.limit === undefined || dto.limit < 1 ? this.DEFAULT_ITEMS_LIMIT : dto.limit
-    const upperBoundLimit = limit < this.maxItemsSyncLimit ? limit : this.maxItemsSyncLimit
-
-    const itemQuery: ItemQuery = {
-      userUuid: dto.userUuid,
-      lastSyncTime,
-      syncTimeComparison,
-      contentType: dto.contentType,
-      deleted: lastSyncTime ? undefined : false,
-      sortBy: 'updated_at_timestamp',
-      sortOrder: 'ASC',
-      limit: upperBoundLimit,
-    }
-
-    const itemUuidsToFetch = await this.itemTransferCalculator.computeItemUuidsToFetch(
-      itemQuery,
-      this.contentSizeTransferLimit,
-    )
-    let items: Array<Item> = []
-    if (itemUuidsToFetch.length > 0) {
-      items = await this.itemRepository.findAll({
-        uuids: itemUuidsToFetch,
-        sortBy: 'updated_at_timestamp',
-        sortOrder: 'ASC',
-      })
-    }
-    const totalItemsCount = await this.itemRepository.countAll(itemQuery)
-
-    let cursorToken = undefined
-    if (totalItemsCount > upperBoundLimit) {
-      const lastSyncTime = items[items.length - 1].props.timestamps.updatedAt / Time.MicrosecondsInASecond
-      cursorToken = Buffer.from(`${this.SYNC_TOKEN_VERSION}:${lastSyncTime}`, 'utf-8').toString('base64')
-    }
-
-    return {
-      items,
-      cursorToken,
-    }
-  }
-
-  async saveItems(dto: SaveItemsDTO): Promise<SaveItemsResult> {
-    const savedItems: Array<Item> = []
-    const conflicts: Array<ItemConflict> = []
-
-    const lastUpdatedTimestamp = this.timer.getTimestampInMicroseconds()
-
-    for (const itemHash of dto.itemHashes) {
-      if (dto.readOnlyAccess) {
-        conflicts.push({
-          unsavedItem: itemHash,
-          type: ConflictType.ReadOnlyError,
-        })
-
-        continue
-      }
-
-      const existingItem = await this.itemRepository.findByUuid(itemHash.uuid)
-      const processingResult = await this.itemSaveValidator.validate({
-        userUuid: dto.userUuid,
-        apiVersion: dto.apiVersion,
-        itemHash,
-        existingItem,
-      })
-      if (!processingResult.passed) {
-        if (processingResult.conflict) {
-          conflicts.push(processingResult.conflict)
-        }
-        if (processingResult.skipped) {
-          savedItems.push(processingResult.skipped)
-        }
-
-        continue
-      }
-
-      if (existingItem) {
-        const udpatedItemOrError = await this.updateExistingItem.execute({
-          existingItem,
-          itemHash,
-          sessionUuid: dto.sessionUuid,
-        })
-        if (udpatedItemOrError.isFailed()) {
-          this.logger.error(
-            `[${dto.userUuid}] Updating item ${itemHash.uuid} failed. Error: ${udpatedItemOrError.getError()}`,
-          )
-
-          conflicts.push({
-            unsavedItem: itemHash,
-            type: ConflictType.UuidConflict,
-          })
-
-          continue
-        }
-        const updatedItem = udpatedItemOrError.getValue()
-
-        savedItems.push(updatedItem)
-      } else {
-        try {
-          const newItemOrError = await this.saveNewItem.execute({
-            userUuid: dto.userUuid,
-            itemHash,
-            sessionUuid: dto.sessionUuid,
-          })
-          if (newItemOrError.isFailed()) {
-            this.logger.error(
-              `[${dto.userUuid}] Saving item ${itemHash.uuid} failed. Error: ${newItemOrError.getError()}`,
-            )
-
-            conflicts.push({
-              unsavedItem: itemHash,
-              type: ConflictType.UuidConflict,
-            })
-
-            continue
-          }
-          const newItem = newItemOrError.getValue()
-
-          savedItems.push(newItem)
-        } catch (error) {
-          this.logger.error(`[${dto.userUuid}] Saving item ${itemHash.uuid} failed. Error: ${(error as Error).message}`)
-
-          conflicts.push({
-            unsavedItem: itemHash,
-            type: ConflictType.UuidConflict,
-          })
-
-          continue
-        }
-      }
-    }
-
-    const syncToken = this.calculateSyncToken(lastUpdatedTimestamp, savedItems)
-
-    return {
-      savedItems,
-      conflicts,
-      syncToken,
-    }
-  }
-
-  async frontLoadKeysItemsToTop(userUuid: string, retrievedItems: Array<Item>): Promise<Array<Item>> {
-    const itemsKeys = await this.itemRepository.findAll({
-      userUuid,
-      contentType: ContentType.TYPES.ItemsKey,
-      sortBy: 'updated_at_timestamp',
-      sortOrder: 'ASC',
-    })
-
-    const retrievedItemsIds: Array<string> = retrievedItems.map((item: Item) => item.id.toString())
-
-    itemsKeys.forEach((itemKey: Item) => {
-      if (retrievedItemsIds.indexOf(itemKey.id.toString()) === -1) {
-        retrievedItems.unshift(itemKey)
-      }
-    })
-
-    return retrievedItems
-  }
-
-  private calculateSyncToken(lastUpdatedTimestamp: number, savedItems: Array<Item>): string {
-    if (savedItems.length) {
-      const sortedItems = savedItems.sort((itemA: Item, itemB: Item) => {
-        return itemA.props.timestamps.updatedAt > itemB.props.timestamps.updatedAt ? 1 : -1
-      })
-      lastUpdatedTimestamp = sortedItems[sortedItems.length - 1].props.timestamps.updatedAt
-    }
-
-    const lastUpdatedTimestampWithMicrosecondPreventingSyncDoubles = lastUpdatedTimestamp + 1
-
-    return Buffer.from(
-      `${this.SYNC_TOKEN_VERSION}:${
-        lastUpdatedTimestampWithMicrosecondPreventingSyncDoubles / Time.MicrosecondsInASecond
-      }`,
-      'utf-8',
-    ).toString('base64')
-  }
-
-  private getLastSyncTime(dto: GetItemsDTO): number | undefined {
-    let token = dto.syncToken
-    if (dto.cursorToken !== undefined && dto.cursorToken !== null) {
-      token = dto.cursorToken
-    }
-
-    if (!token) {
-      return undefined
-    }
-
-    const decodedToken = Buffer.from(token, 'base64').toString('utf-8')
-
-    const tokenParts = decodedToken.split(':')
-    const version = tokenParts.shift()
-
-    switch (version) {
-      case '1':
-        return this.timer.convertStringDateToMicroseconds(tokenParts.join(':'))
-      case '2':
-        return +tokenParts[0] * Time.MicrosecondsInASecond
-      default:
-        throw Error('Sync token is missing version part')
-    }
-  }
-}

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

@@ -1,11 +0,0 @@
-import { GetItemsDTO } from './GetItemsDTO'
-import { GetItemsResult } from './GetItemsResult'
-import { Item } from './Item'
-import { SaveItemsDTO } from './SaveItemsDTO'
-import { SaveItemsResult } from './SaveItemsResult'
-
-export interface ItemServiceInterface {
-  getItems(dto: GetItemsDTO): Promise<GetItemsResult>
-  saveItems(dto: SaveItemsDTO): Promise<SaveItemsResult>
-  frontLoadKeysItemsToTop(userUuid: string, retrievedItems: Array<Item>): Promise<Array<Item>>
-}

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

@@ -1,8 +0,0 @@
-import { Item } from './Item'
-import { ItemConflict } from './ItemConflict'
-
-export type SaveItemsResult = {
-  savedItems: Array<Item>
-  conflicts: Array<ItemConflict>
-  syncToken: string
-}

+ 16 - 12
packages/syncing-server/src/Domain/Item/SaveRule/ContentFilter.spec.ts

@@ -5,6 +5,7 @@ import { Item } from '../Item'
 
 import { ContentFilter } from './ContentFilter'
 import { ContentType } from '@standardnotes/domain-core'
+import { ItemHash } from '../ItemHash'
 
 describe('ContentFilter', () => {
   let existingItem: Item
@@ -14,25 +15,25 @@ describe('ContentFilter', () => {
     const invalidContents = [[], { foo: 'bar' }, [{ foo: 'bar' }], 123, new Date(1)]
 
     for (const invalidContent of invalidContents) {
+      const itemHash = ItemHash.create({
+        uuid: '123e4567-e89b-12d3-a456-426655440000',
+        content: invalidContent as unknown as string,
+        content_type: ContentType.TYPES.Note,
+        user_uuid: '1-2-3',
+        key_system_identifier: null,
+        shared_vault_uuid: null,
+      }).getValue()
       const result = await createFilter().check({
         userUuid: '1-2-3',
         apiVersion: ApiVersion.v20200115,
-        itemHash: {
-          uuid: '123e4567-e89b-12d3-a456-426655440000',
-          content: invalidContent as unknown as string,
-          content_type: ContentType.TYPES.Note,
-        },
+        itemHash,
         existingItem: null,
       })
 
       expect(result).toEqual({
         passed: false,
         conflict: {
-          unsavedItem: {
-            uuid: '123e4567-e89b-12d3-a456-426655440000',
-            content: invalidContent,
-            content_type: ContentType.TYPES.Note,
-          },
+          unsavedItem: itemHash,
           type: 'content_error',
         },
       })
@@ -46,11 +47,14 @@ describe('ContentFilter', () => {
       const result = await createFilter().check({
         userUuid: '1-2-3',
         apiVersion: ApiVersion.v20200115,
-        itemHash: {
+        itemHash: ItemHash.create({
           uuid: '123e4567-e89b-12d3-a456-426655440000',
           content: validContent as unknown as string,
           content_type: ContentType.TYPES.Note,
-        },
+          user_uuid: '1-2-3',
+          key_system_identifier: null,
+          shared_vault_uuid: null,
+        }).getValue(),
         existingItem,
       })
 

+ 2 - 2
packages/syncing-server/src/Domain/Item/SaveRule/ContentFilter.ts

@@ -5,13 +5,13 @@ import { ConflictType } from '@standardnotes/responses'
 
 export class ContentFilter implements ItemSaveRuleInterface {
   async check(dto: ItemSaveValidationDTO): Promise<ItemSaveRuleResult> {
-    if (dto.itemHash.content === undefined || dto.itemHash.content === null) {
+    if (dto.itemHash.props.content === undefined || dto.itemHash.props.content === null) {
       return {
         passed: true,
       }
     }
 
-    const validContent = typeof dto.itemHash.content === 'string'
+    const validContent = typeof dto.itemHash.props.content === 'string'
 
     if (!validContent) {
       return {

+ 19 - 12
packages/syncing-server/src/Domain/Item/SaveRule/ContentTypeFilter.spec.ts

@@ -4,6 +4,7 @@ import { ApiVersion } from '../../Api/ApiVersion'
 import { Item } from '../Item'
 
 import { ContentTypeFilter } from './ContentTypeFilter'
+import { ItemHash } from '../ItemHash'
 
 describe('ContentTypeFilter', () => {
   let existingItem: Item
@@ -22,23 +23,24 @@ describe('ContentTypeFilter', () => {
     ]
 
     for (const invalidContentType of invalidContentTypes) {
+      const itemHash = ItemHash.create({
+        uuid: '123e4567-e89b-12d3-a456-426655440000',
+        content_type: invalidContentType,
+        user_uuid: '1-2-3',
+        key_system_identifier: null,
+        shared_vault_uuid: null,
+      }).getValue()
       const result = await createFilter().check({
         userUuid: '1-2-3',
         apiVersion: ApiVersion.v20200115,
-        itemHash: {
-          uuid: '123e4567-e89b-12d3-a456-426655440000',
-          content_type: invalidContentType,
-        },
+        itemHash,
         existingItem: null,
       })
 
       expect(result).toEqual({
         passed: false,
         conflict: {
-          unsavedItem: {
-            uuid: '123e4567-e89b-12d3-a456-426655440000',
-            content_type: invalidContentType,
-          },
+          unsavedItem: itemHash,
           type: 'content_type_error',
         },
       })
@@ -49,13 +51,18 @@ describe('ContentTypeFilter', () => {
     const validContentTypes = ['Note', 'SN|ItemsKey', 'SN|Component', 'SN|Editor', 'SN|ExtensionRepo', 'Tag']
 
     for (const validContentType of validContentTypes) {
+      const itemHash = ItemHash.create({
+        uuid: '123e4567-e89b-12d3-a456-426655440000',
+        content_type: validContentType,
+        user_uuid: '1-2-3',
+        key_system_identifier: null,
+        shared_vault_uuid: null,
+      }).getValue()
+
       const result = await createFilter().check({
         userUuid: '1-2-3',
         apiVersion: ApiVersion.v20200115,
-        itemHash: {
-          uuid: '123e4567-e89b-12d3-a456-426655440000',
-          content_type: validContentType,
-        },
+        itemHash,
         existingItem,
       })
 

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

@@ -7,7 +7,7 @@ import { ItemSaveRuleInterface } from './ItemSaveRuleInterface'
 
 export class ContentTypeFilter implements ItemSaveRuleInterface {
   async check(dto: ItemSaveValidationDTO): Promise<ItemSaveRuleResult> {
-    const contentTypeOrError = ContentType.create(dto.itemHash.content_type)
+    const contentTypeOrError = ContentType.create(dto.itemHash.props.content_type)
     if (contentTypeOrError.isFailed()) {
       return {
         passed: false,

+ 49 - 20
packages/syncing-server/src/Domain/Item/SaveRule/OwnershipFilter.spec.ts

@@ -5,6 +5,7 @@ import { Item } from '../Item'
 
 import { OwnershipFilter } from './OwnershipFilter'
 import { Uuid, ContentType, Dates, Timestamps, UniqueEntityId } from '@standardnotes/domain-core'
+import { ItemHash } from '../ItemHash'
 
 describe('OwnershipFilter', () => {
   let existingItem: Item
@@ -30,23 +31,29 @@ describe('OwnershipFilter', () => {
   })
 
   it('should filter out items belonging to a different user', async () => {
+    const itemHash = ItemHash.create({
+      uuid: '2-3-4',
+      content_type: ContentType.TYPES.Note,
+      user_uuid: '00000000-0000-0000-0000-000000000000',
+      content: 'foobar',
+      created_at: '2020-01-01T00:00:00.000Z',
+      updated_at: '2020-01-01T00:00:00.000Z',
+      created_at_timestamp: 123,
+      updated_at_timestamp: 123,
+      key_system_identifier: null,
+      shared_vault_uuid: null,
+    }).getValue()
     const result = await createFilter().check({
       userUuid: '00000000-0000-0000-0000-000000000001',
       apiVersion: ApiVersion.v20200115,
-      itemHash: {
-        uuid: '2-3-4',
-        content_type: ContentType.TYPES.Note,
-      },
+      itemHash,
       existingItem,
     })
 
     expect(result).toEqual({
       passed: false,
       conflict: {
-        unsavedItem: {
-          uuid: '2-3-4',
-          content_type: ContentType.TYPES.Note,
-        },
+        unsavedItem: itemHash,
         type: 'uuid_conflict',
       },
     })
@@ -56,10 +63,18 @@ describe('OwnershipFilter', () => {
     const result = await createFilter().check({
       userUuid: '00000000-0000-0000-0000-000000000000',
       apiVersion: ApiVersion.v20200115,
-      itemHash: {
+      itemHash: ItemHash.create({
         uuid: '2-3-4',
         content_type: ContentType.TYPES.Note,
-      },
+        user_uuid: '00000000-0000-0000-0000-000000000000',
+        content: 'foobar',
+        created_at: '2020-01-01T00:00:00.000Z',
+        updated_at: '2020-01-01T00:00:00.000Z',
+        created_at_timestamp: 123,
+        updated_at_timestamp: 123,
+        key_system_identifier: null,
+        shared_vault_uuid: null,
+      }).getValue(),
       existingItem,
     })
 
@@ -72,10 +87,18 @@ describe('OwnershipFilter', () => {
     const result = await createFilter().check({
       userUuid: '00000000-0000-0000-0000-000000000000',
       apiVersion: ApiVersion.v20200115,
-      itemHash: {
+      itemHash: ItemHash.create({
         uuid: '2-3-4',
         content_type: ContentType.TYPES.Note,
-      },
+        user_uuid: '00000000-0000-0000-0000-000000000000',
+        content: 'foobar',
+        created_at: '2020-01-01T00:00:00.000Z',
+        updated_at: '2020-01-01T00:00:00.000Z',
+        created_at_timestamp: 123,
+        updated_at_timestamp: 123,
+        key_system_identifier: null,
+        shared_vault_uuid: null,
+      }).getValue(),
       existingItem: null,
     })
 
@@ -85,23 +108,29 @@ describe('OwnershipFilter', () => {
   })
 
   it('should return an error if the user uuid is invalid', async () => {
+    const itemHash = ItemHash.create({
+      uuid: '2-3-4',
+      content_type: ContentType.TYPES.Note,
+      user_uuid: '00000000-0000-0000-0000-000000000000',
+      content: 'foobar',
+      created_at: '2020-01-01T00:00:00.000Z',
+      updated_at: '2020-01-01T00:00:00.000Z',
+      created_at_timestamp: 123,
+      updated_at_timestamp: 123,
+      key_system_identifier: null,
+      shared_vault_uuid: null,
+    }).getValue()
     const result = await createFilter().check({
       userUuid: 'invalid',
       apiVersion: ApiVersion.v20200115,
-      itemHash: {
-        uuid: '2-3-4',
-        content_type: ContentType.TYPES.Note,
-      },
+      itemHash,
       existingItem,
     })
 
     expect(result).toEqual({
       passed: false,
       conflict: {
-        unsavedItem: {
-          uuid: '2-3-4',
-          content_type: ContentType.TYPES.Note,
-        },
+        unsavedItem: itemHash,
         type: 'uuid_error',
       },
     })

+ 52 - 28
packages/syncing-server/src/Domain/Item/SaveRule/TimeDifferenceFilter.spec.ts

@@ -42,8 +42,11 @@ describe('TimeDifferenceFilter', () => {
       new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
     ).getValue()
 
-    itemHash = {
+    itemHash = ItemHash.create({
       uuid: '1-2-3',
+      user_uuid: '00000000-0000-0000-0000-000000000000',
+      key_system_identifier: null,
+      shared_vault_uuid: null,
       content: 'asdqwe1',
       content_type: ContentType.TYPES.Note,
       duplicate_of: null,
@@ -57,7 +60,7 @@ describe('TimeDifferenceFilter', () => {
         timeHelper.convertMicrosecondsToDate(existingItem.props.timestamps.updatedAt + 1),
         'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
       ),
-    } as jest.Mocked<ItemHash>
+    }).getValue()
   })
 
   it('should leave non existing items', async () => {
@@ -74,8 +77,11 @@ describe('TimeDifferenceFilter', () => {
   })
 
   it('should leave items from legacy clients', async () => {
-    delete itemHash.updated_at
-    delete itemHash.updated_at_timestamp
+    itemHash = ItemHash.create({
+      ...itemHash.props,
+      updated_at: undefined,
+      updated_at_timestamp: undefined,
+    }).getValue()
 
     const result = await createFilter().check({
       userUuid: '1-2-3',
@@ -90,7 +96,10 @@ describe('TimeDifferenceFilter', () => {
   })
 
   it('should filter out items having update at timestamp different in microseconds precision', async () => {
-    itemHash.updated_at_timestamp = existingItem.props.timestamps.updatedAt + 1
+    itemHash = ItemHash.create({
+      ...itemHash.props,
+      updated_at_timestamp: existingItem.props.timestamps.updatedAt + 1,
+    }).getValue()
 
     const result = await createFilter().check({
       userUuid: '1-2-3',
@@ -109,7 +118,10 @@ describe('TimeDifferenceFilter', () => {
   })
 
   it('should leave items having update at timestamp same in microseconds precision', async () => {
-    itemHash.updated_at_timestamp = existingItem.props.timestamps.updatedAt
+    itemHash = ItemHash.create({
+      ...itemHash.props,
+      updated_at_timestamp: existingItem.props.timestamps.updatedAt,
+    }).getValue()
 
     const result = await createFilter().check({
       userUuid: '1-2-3',
@@ -124,14 +136,17 @@ describe('TimeDifferenceFilter', () => {
   })
 
   it('should filter out items having update at timestamp different by a second for legacy clients', async () => {
-    itemHash.updated_at = timeHelper.formatDate(
-      new Date(
-        timeHelper.convertMicrosecondsToMilliseconds(existingItem.props.timestamps.updatedAt) +
-          Time.MicrosecondsInASecond +
-          1,
+    itemHash = ItemHash.create({
+      ...itemHash.props,
+      updated_at: timeHelper.formatDate(
+        new Date(
+          timeHelper.convertMicrosecondsToMilliseconds(existingItem.props.timestamps.updatedAt) +
+            Time.MicrosecondsInASecond +
+            1,
+        ),
+        'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
       ),
-      'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
-    )
+    }).getValue()
 
     const result = await createFilter().check({
       userUuid: '1-2-3',
@@ -150,10 +165,13 @@ describe('TimeDifferenceFilter', () => {
   })
 
   it('should leave items having update at timestamp different by less then a second for legacy clients', async () => {
-    itemHash.updated_at = timeHelper.formatDate(
-      timeHelper.convertMicrosecondsToDate(existingItem.props.timestamps.updatedAt),
-      'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
-    )
+    itemHash = ItemHash.create({
+      ...itemHash.props,
+      updated_at: timeHelper.formatDate(
+        timeHelper.convertMicrosecondsToDate(existingItem.props.timestamps.updatedAt),
+        'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
+      ),
+    }).getValue()
 
     const result = await createFilter().check({
       userUuid: '1-2-3',
@@ -168,14 +186,17 @@ describe('TimeDifferenceFilter', () => {
   })
 
   it('should filter out items having update at timestamp different by a millisecond', async () => {
-    itemHash.updated_at = timeHelper.formatDate(
-      new Date(
-        timeHelper.convertMicrosecondsToMilliseconds(existingItem.props.timestamps.updatedAt) +
-          Time.MicrosecondsInAMillisecond +
-          1,
+    itemHash = ItemHash.create({
+      ...itemHash.props,
+      updated_at: timeHelper.formatDate(
+        new Date(
+          timeHelper.convertMicrosecondsToMilliseconds(existingItem.props.timestamps.updatedAt) +
+            Time.MicrosecondsInAMillisecond +
+            1,
+        ),
+        'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
       ),
-      'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
-    )
+    }).getValue()
 
     const result = await createFilter().check({
       userUuid: '1-2-3',
@@ -194,10 +215,13 @@ describe('TimeDifferenceFilter', () => {
   })
 
   it('should leave items having update at timestamp different by less than a millisecond', async () => {
-    itemHash.updated_at = timeHelper.formatDate(
-      timeHelper.convertMicrosecondsToDate(existingItem.props.timestamps.updatedAt),
-      'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
-    )
+    itemHash = ItemHash.create({
+      ...itemHash.props,
+      updated_at: timeHelper.formatDate(
+        timeHelper.convertMicrosecondsToDate(existingItem.props.timestamps.updatedAt),
+        'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
+      ),
+    }).getValue()
 
     const result = await createFilter().check({
       userUuid: '1-2-3',

+ 4 - 4
packages/syncing-server/src/Domain/Item/SaveRule/TimeDifferenceFilter.ts

@@ -17,11 +17,11 @@ export class TimeDifferenceFilter implements ItemSaveRuleInterface {
       }
     }
 
-    let incomingUpdatedAtTimestamp = dto.itemHash.updated_at_timestamp
+    let incomingUpdatedAtTimestamp = dto.itemHash.props.updated_at_timestamp
     if (incomingUpdatedAtTimestamp === undefined) {
       incomingUpdatedAtTimestamp =
-        dto.itemHash.updated_at !== undefined
-          ? this.timer.convertStringDateToMicroseconds(dto.itemHash.updated_at)
+        dto.itemHash.props.updated_at !== undefined
+          ? this.timer.convertStringDateToMicroseconds(dto.itemHash.props.updated_at)
           : this.timer.convertStringDateToMicroseconds(new Date(0).toString())
     }
 
@@ -66,7 +66,7 @@ export class TimeDifferenceFilter implements ItemSaveRuleInterface {
   }
 
   private itemHashHasMicrosecondsPrecision(itemHash: ItemHash) {
-    return itemHash.updated_at_timestamp !== undefined
+    return itemHash.props.updated_at_timestamp !== undefined
   }
 
   private getMinimalConflictIntervalMicroseconds(apiVersion?: string): number {

+ 0 - 4
packages/syncing-server/src/Domain/Message/Message.ts

@@ -3,10 +3,6 @@ import { Entity, Result, UniqueEntityId } from '@standardnotes/domain-core'
 import { MessageProps } from './MessageProps'
 
 export class Message extends Entity<MessageProps> {
-  get id(): UniqueEntityId {
-    return this._id
-  }
-
   private constructor(props: MessageProps, id?: UniqueEntityId) {
     super(props, id)
   }

+ 0 - 4
packages/syncing-server/src/Domain/Notifications/Notification.ts

@@ -3,10 +3,6 @@ import { Entity, Result, UniqueEntityId } from '@standardnotes/domain-core'
 import { NotificationProps } from './NotificationProps'
 
 export class Notification extends Entity<NotificationProps> {
-  get id(): UniqueEntityId {
-    return this._id
-  }
-
   private constructor(props: NotificationProps, id?: UniqueEntityId) {
     super(props, id)
   }

+ 18 - 0
packages/syncing-server/src/Domain/SharedVault/Item/SharedVaultItem.spec.ts

@@ -0,0 +1,18 @@
+import { Timestamps, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
+
+import { SharedVaultItem } from './SharedVaultItem'
+
+describe('SharedVaultItem', () => {
+  it('should create an entity', () => {
+    const entityOrError = SharedVaultItem.create({
+      itemId: new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
+      sharedVaultId: new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
+      keySystemIdentifier: 'key-system-identifier',
+      lastEditedBy: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+      timestamps: Timestamps.create(123456789, 123456789).getValue(),
+    })
+
+    expect(entityOrError.isFailed()).toBeFalsy()
+    expect(entityOrError.getValue().id).not.toBeNull()
+  })
+})

+ 13 - 0
packages/syncing-server/src/Domain/SharedVault/Item/SharedVaultItem.ts

@@ -0,0 +1,13 @@
+import { Entity, Result, UniqueEntityId } from '@standardnotes/domain-core'
+
+import { SharedVaultItemProps } from './SharedVaultItemProps'
+
+export class SharedVaultItem extends Entity<SharedVaultItemProps> {
+  private constructor(props: SharedVaultItemProps, id?: UniqueEntityId) {
+    super(props, id)
+  }
+
+  static create(props: SharedVaultItemProps, id?: UniqueEntityId): Result<SharedVaultItem> {
+    return Result.ok<SharedVaultItem>(new SharedVaultItem(props, id))
+  }
+}

+ 9 - 0
packages/syncing-server/src/Domain/SharedVault/Item/SharedVaultItemProps.ts

@@ -0,0 +1,9 @@
+import { Timestamps, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
+
+export interface SharedVaultItemProps {
+  sharedVaultId: UniqueEntityId
+  itemId: UniqueEntityId
+  keySystemIdentifier: string
+  lastEditedBy: Uuid
+  timestamps: Timestamps
+}

+ 8 - 0
packages/syncing-server/src/Domain/SharedVault/Item/SharedVaultItemRepositoryInterface.ts

@@ -0,0 +1,8 @@
+import { UniqueEntityId } from '@standardnotes/domain-core'
+
+import { SharedVaultItem } from './SharedVaultItem'
+
+export interface SharedVaultItemRepositoryInterface {
+  save(sharedVaultItem: SharedVaultItem): Promise<void>
+  findBySharedVaultId(sharedVaultId: UniqueEntityId): Promise<SharedVaultItem[]>
+}

+ 2 - 1
packages/syncing-server/src/Domain/SharedVault/SharedVault.spec.ts

@@ -3,12 +3,13 @@ import { Timestamps, Uuid } from '@standardnotes/domain-core'
 import { SharedVault } from './SharedVault'
 
 describe('SharedVault', () => {
-  it('should create an entity', () => {
+  it('should create an aggregate', () => {
     const entityOrError = SharedVault.create({
       fileUploadBytesLimit: 1_000_000,
       fileUploadBytesUsed: 0,
       timestamps: Timestamps.create(123456789, 123456789).getValue(),
       userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+      sharedVaultItems: [],
     })
 
     expect(entityOrError.isFailed()).toBeFalsy()

+ 2 - 6
packages/syncing-server/src/Domain/SharedVault/SharedVault.ts

@@ -1,12 +1,8 @@
-import { Entity, Result, UniqueEntityId } from '@standardnotes/domain-core'
+import { Aggregate, Result, UniqueEntityId } from '@standardnotes/domain-core'
 
 import { SharedVaultProps } from './SharedVaultProps'
 
-export class SharedVault extends Entity<SharedVaultProps> {
-  get id(): UniqueEntityId {
-    return this._id
-  }
-
+export class SharedVault extends Aggregate<SharedVaultProps> {
   private constructor(props: SharedVaultProps, id?: UniqueEntityId) {
     super(props, id)
   }

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

@@ -1,8 +1,11 @@
 import { Uuid, Timestamps } from '@standardnotes/domain-core'
 
+import { SharedVaultItem } from './Item/SharedVaultItem'
+
 export interface SharedVaultProps {
   userUuid: Uuid
   fileUploadBytesUsed: number
   fileUploadBytesLimit: number
   timestamps: Timestamps
+  sharedVaultItems: SharedVaultItem[]
 }

+ 0 - 4
packages/syncing-server/src/Domain/SharedVault/User/Invite/SharedVaultInvite.ts

@@ -3,10 +3,6 @@ import { Entity, Result, UniqueEntityId } from '@standardnotes/domain-core'
 import { SharedVaultInviteProps } from './SharedVaultInviteProps'
 
 export class SharedVaultInvite extends Entity<SharedVaultInviteProps> {
-  get id(): UniqueEntityId {
-    return this._id
-  }
-
   private constructor(props: SharedVaultInviteProps, id?: UniqueEntityId) {
     super(props, id)
   }

+ 0 - 4
packages/syncing-server/src/Domain/SharedVault/User/SharedVaultUser.ts

@@ -3,10 +3,6 @@ import { Entity, Result, UniqueEntityId } from '@standardnotes/domain-core'
 import { SharedVaultUserProps } from './SharedVaultUserProps'
 
 export class SharedVaultUser extends Entity<SharedVaultUserProps> {
-  get id(): UniqueEntityId {
-    return this._id
-  }
-
   private constructor(props: SharedVaultUserProps, id?: UniqueEntityId) {
     super(props, id)
   }

+ 1 - 0
packages/syncing-server/src/Domain/UseCase/SharedVaults/CreateSharedVault/CreateSharedVault.ts

@@ -32,6 +32,7 @@ export class CreateSharedVault implements UseCaseInterface<CreateSharedVaultResu
       fileUploadBytesUsed: 0,
       userUuid,
       timestamps,
+      sharedVaultItems: [],
     })
     if (sharedVaultOrError.isFailed()) {
       return Result.fail(sharedVaultOrError.getError())

+ 1 - 0
packages/syncing-server/src/Domain/UseCase/SharedVaults/CreateSharedVaultFileValetToken/CreateSharedVaultFileValetToken.spec.ts

@@ -24,6 +24,7 @@ describe('CreateSharedVaultFileValetToken', () => {
       fileUploadBytesUsed: 2,
       userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
       timestamps: Timestamps.create(123, 123).getValue(),
+      sharedVaultItems: [],
     }).getValue()
 
     sharedVaultRepository = {} as jest.Mocked<SharedVaultRepositoryInterface>

+ 2 - 0
packages/syncing-server/src/Domain/UseCase/SharedVaults/DeleteSharedVault/DeleteSharedVault.spec.ts

@@ -31,6 +31,7 @@ describe('DeleteSharedVault', () => {
       fileUploadBytesUsed: 2,
       userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
       timestamps: Timestamps.create(123, 123).getValue(),
+      sharedVaultItems: [],
     }).getValue()
     sharedVaultRepository = {} as jest.Mocked<SharedVaultRepositoryInterface>
     sharedVaultRepository.findByUuid = jest.fn().mockResolvedValue(sharedVault)
@@ -115,6 +116,7 @@ describe('DeleteSharedVault', () => {
       fileUploadBytesUsed: 2,
       userUuid: Uuid.create('00000000-0000-0000-0000-000000000001').getValue(),
       timestamps: Timestamps.create(123, 123).getValue(),
+      sharedVaultItems: [],
     }).getValue()
     sharedVaultRepository.findByUuid = jest.fn().mockResolvedValue(sharedVault)
     const useCase = createUseCase()

+ 2 - 0
packages/syncing-server/src/Domain/UseCase/SharedVaults/GetSharedVaultUsers/GetSharedVaultUsers.spec.ts

@@ -20,6 +20,7 @@ describe('GetSharedVaultUsers', () => {
       fileUploadBytesUsed: 2,
       userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
       timestamps: Timestamps.create(123, 123).getValue(),
+      sharedVaultItems: [],
     }).getValue()
 
     sharedVaultUser = SharedVaultUser.create({
@@ -66,6 +67,7 @@ describe('GetSharedVaultUsers', () => {
       fileUploadBytesUsed: 2,
       userUuid: Uuid.create('00000000-0000-0000-0000-000000000001').getValue(),
       timestamps: Timestamps.create(123, 123).getValue(),
+      sharedVaultItems: [],
     }).getValue()
     sharedVaultRepository.findByUuid = jest.fn().mockResolvedValue(sharedVault)
 

+ 1 - 0
packages/syncing-server/src/Domain/UseCase/SharedVaults/GetSharedVaults/GetSharedVaults.spec.ts

@@ -29,6 +29,7 @@ describe('GetSharedVaults', () => {
       timestamps: Timestamps.create(123, 123).getValue(),
       fileUploadBytesLimit: 123,
       fileUploadBytesUsed: 123,
+      sharedVaultItems: [],
     }).getValue()
     sharedVaultRepository = {} as jest.Mocked<SharedVaultRepositoryInterface>
     sharedVaultRepository.findByUuids = jest.fn().mockResolvedValue([sharedVault])

+ 2 - 0
packages/syncing-server/src/Domain/UseCase/SharedVaults/InviteUserToSharedVault/InviteUserToSharedVault.spec.ts

@@ -21,6 +21,7 @@ describe('InviteUserToSharedVault', () => {
       fileUploadBytesUsed: 2,
       userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
       timestamps: Timestamps.create(123, 123).getValue(),
+      sharedVaultItems: [],
     }).getValue()
     sharedVaultRepository = {} as jest.Mocked<SharedVaultRepositoryInterface>
     sharedVaultRepository.findByUuid = jest.fn().mockResolvedValue(sharedVault)
@@ -152,6 +153,7 @@ describe('InviteUserToSharedVault', () => {
       fileUploadBytesUsed: 2,
       userUuid: Uuid.create('10000000-0000-0000-0000-000000000000').getValue(),
       timestamps: Timestamps.create(123, 123).getValue(),
+      sharedVaultItems: [],
     }).getValue()
     sharedVaultRepository.findByUuid = jest.fn().mockResolvedValue(sharedVault)
 

+ 2 - 0
packages/syncing-server/src/Domain/UseCase/SharedVaults/RemoveUserFromSharedVault/RemoveUserFromSharedVault.spec.ts

@@ -24,6 +24,7 @@ describe('RemoveUserFromSharedVault', () => {
       fileUploadBytesUsed: 2,
       userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
       timestamps: Timestamps.create(123, 123).getValue(),
+      sharedVaultItems: [],
     }).getValue()
     sharedVaultRepository = {} as jest.Mocked<SharedVaultRepositoryInterface>
     sharedVaultRepository.findByUuid = jest.fn().mockResolvedValue(sharedVault)
@@ -88,6 +89,7 @@ describe('RemoveUserFromSharedVault', () => {
       fileUploadBytesUsed: 2,
       userUuid: Uuid.create('00000000-0000-0000-0000-000000000002').getValue(),
       timestamps: Timestamps.create(123, 123).getValue(),
+      sharedVaultItems: [],
     }).getValue()
     sharedVaultRepository.findByUuid = jest.fn().mockResolvedValue(sharedVault)
 

+ 152 - 0
packages/syncing-server/src/Domain/UseCase/Syncing/GetItems/GetItems.spec.ts

@@ -0,0 +1,152 @@
+import { TimerInterface } from '@standardnotes/time'
+import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
+import { ItemTransferCalculatorInterface } from '../../../Item/ItemTransferCalculatorInterface'
+import { GetItems } from './GetItems'
+import { Item } from '../../../Item/Item'
+import { ContentType, Dates, Timestamps, Uuid } from '@standardnotes/domain-core'
+
+describe('GetItems', () => {
+  let itemRepository: ItemRepositoryInterface
+  const contentSizeTransferLimit = 100
+  let itemTransferCalculator: ItemTransferCalculatorInterface
+  let timer: TimerInterface
+  const maxItemsSyncLimit = 100
+  let item: Item
+
+  const createUseCase = () =>
+    new GetItems(itemRepository, contentSizeTransferLimit, itemTransferCalculator, timer, maxItemsSyncLimit)
+
+  beforeEach(() => {
+    item = 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()
+
+    itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
+    itemRepository.findAll = jest.fn().mockResolvedValue([item])
+    itemRepository.countAll = jest.fn().mockResolvedValue(1)
+
+    itemTransferCalculator = {} as jest.Mocked<ItemTransferCalculatorInterface>
+    itemTransferCalculator.computeItemUuidsToFetch = jest.fn().mockResolvedValue(['item-uuid'])
+
+    timer = {} as jest.Mocked<TimerInterface>
+    timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123)
+    timer.convertStringDateToMicroseconds = jest.fn().mockReturnValue(123)
+  })
+
+  it('returns items', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userUuid: 'user-uuid',
+      cursorToken: undefined,
+      contentType: undefined,
+      limit: 10,
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+    expect(result.getValue()).toEqual({
+      items: [item],
+      cursorToken: undefined,
+    })
+  })
+
+  it('should return cursor token if there are more items to fetch', async () => {
+    itemRepository.countAll = jest.fn().mockResolvedValue(101)
+
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userUuid: 'user-uuid',
+      cursorToken: undefined,
+      contentType: undefined,
+      limit: undefined,
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+    expect(result.getValue()).toEqual({
+      items: [item],
+      cursorToken: 'MjowLjAwMDEyMw==',
+    })
+  })
+
+  it('should return items based on the cursort token passed', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userUuid: 'user-uuid',
+      cursorToken: 'MjowLjAwMDEyMw==',
+      contentType: undefined,
+      limit: undefined,
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+    expect(result.getValue()).toEqual({
+      items: [item],
+      cursorToken: undefined,
+    })
+  })
+
+  it('should return items based on a sync token containing string date', async () => {
+    const useCase = createUseCase()
+
+    const syncTokenData = '1:2021-01-01T00:00:00.000Z'
+    const syncToken = Buffer.from(syncTokenData, 'utf-8').toString('base64')
+
+    const result = await useCase.execute({
+      userUuid: 'user-uuid',
+      syncToken,
+      contentType: undefined,
+      limit: undefined,
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+    expect(result.getValue()).toEqual({
+      items: [item],
+      cursorToken: undefined,
+    })
+  })
+
+  it('should return error if the sync token is invalid', async () => {
+    const useCase = createUseCase()
+
+    const syncTokenData = 'invalid'
+    const syncToken = Buffer.from(syncTokenData, 'utf-8').toString('base64')
+
+    const result = await useCase.execute({
+      userUuid: 'user-uuid',
+      syncToken,
+      contentType: undefined,
+      limit: undefined,
+    })
+
+    expect(result.isFailed()).toBeTruthy()
+    expect(result.getError()).toEqual('Sync token is missing version part')
+  })
+
+  it('should guard the upper bound limit of items to fetch', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userUuid: 'user-uuid',
+      cursorToken: undefined,
+      contentType: undefined,
+      limit: 200,
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+    expect(result.getValue()).toEqual({
+      items: [item],
+      cursorToken: undefined,
+    })
+  })
+})

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

@@ -0,0 +1,95 @@
+import { Result, UseCaseInterface } from '@standardnotes/domain-core'
+import { Time, TimerInterface } from '@standardnotes/time'
+
+import { Item } from '../../../Item/Item'
+import { GetItemsResult } from './GetItemsResult'
+import { ItemQuery } from '../../../Item/ItemQuery'
+import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
+import { ItemTransferCalculatorInterface } from '../../../Item/ItemTransferCalculatorInterface'
+import { GetItemsDTO } from './GetItemsDTO'
+
+export class GetItems implements UseCaseInterface<GetItemsResult> {
+  private readonly DEFAULT_ITEMS_LIMIT = 150
+  private readonly SYNC_TOKEN_VERSION = 2
+
+  constructor(
+    private itemRepository: ItemRepositoryInterface,
+    private contentSizeTransferLimit: number,
+    private itemTransferCalculator: ItemTransferCalculatorInterface,
+    private timer: TimerInterface,
+    private maxItemsSyncLimit: number,
+  ) {}
+
+  async execute(dto: GetItemsDTO): Promise<Result<GetItemsResult>> {
+    const lastSyncTimeOrError = this.getLastSyncTime(dto)
+    if (lastSyncTimeOrError.isFailed()) {
+      return Result.fail(lastSyncTimeOrError.getError())
+    }
+    const lastSyncTime = lastSyncTimeOrError.getValue()
+
+    const syncTimeComparison = dto.cursorToken ? '>=' : '>'
+    const limit = dto.limit === undefined || dto.limit < 1 ? this.DEFAULT_ITEMS_LIMIT : dto.limit
+    const upperBoundLimit = limit < this.maxItemsSyncLimit ? limit : this.maxItemsSyncLimit
+
+    const itemQuery: ItemQuery = {
+      userUuid: dto.userUuid,
+      lastSyncTime: lastSyncTime ?? undefined,
+      syncTimeComparison,
+      contentType: dto.contentType,
+      deleted: lastSyncTime ? undefined : false,
+      sortBy: 'updated_at_timestamp',
+      sortOrder: 'ASC',
+      limit: upperBoundLimit,
+    }
+
+    const itemUuidsToFetch = await this.itemTransferCalculator.computeItemUuidsToFetch(
+      itemQuery,
+      this.contentSizeTransferLimit,
+    )
+    let items: Array<Item> = []
+    if (itemUuidsToFetch.length > 0) {
+      items = await this.itemRepository.findAll({
+        uuids: itemUuidsToFetch,
+        sortBy: 'updated_at_timestamp',
+        sortOrder: 'ASC',
+      })
+    }
+    const totalItemsCount = await this.itemRepository.countAll(itemQuery)
+
+    let cursorToken = undefined
+    if (totalItemsCount > upperBoundLimit) {
+      const lastSyncTime = items[items.length - 1].props.timestamps.updatedAt / Time.MicrosecondsInASecond
+      cursorToken = Buffer.from(`${this.SYNC_TOKEN_VERSION}:${lastSyncTime}`, 'utf-8').toString('base64')
+    }
+
+    return Result.ok({
+      items,
+      cursorToken,
+    })
+  }
+
+  private getLastSyncTime(dto: GetItemsDTO): Result<number | null> {
+    let token = dto.syncToken
+    if (dto.cursorToken !== undefined && dto.cursorToken !== null) {
+      token = dto.cursorToken
+    }
+
+    if (!token) {
+      return Result.ok(null)
+    }
+
+    const decodedToken = Buffer.from(token, 'base64').toString('utf-8')
+
+    const tokenParts = decodedToken.split(':')
+    const version = tokenParts.shift()
+
+    switch (version) {
+      case '1':
+        return Result.ok(this.timer.convertStringDateToMicroseconds(tokenParts.join(':')))
+      case '2':
+        return Result.ok(+tokenParts[0] * Time.MicrosecondsInASecond)
+      default:
+        return Result.fail('Sync token is missing version part')
+    }
+  }
+}

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

@@ -1,4 +1,4 @@
-export type GetItemsDTO = {
+export interface GetItemsDTO {
   userUuid: string
   syncToken?: string | null
   cursorToken?: string | null

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

@@ -0,0 +1,6 @@
+import { Item } from '../../../Item/Item'
+
+export interface GetItemsResult {
+  items: Item[]
+  cursorToken?: string
+}

+ 275 - 0
packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItems.spec.ts

@@ -0,0 +1,275 @@
+import { TimerInterface } from '@standardnotes/time'
+import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
+import { ItemSaveValidatorInterface } from '../../../Item/SaveValidator/ItemSaveValidatorInterface'
+import { SaveItems } from './SaveItems'
+import { SaveNewItem } from '../SaveNewItem/SaveNewItem'
+import { UpdateExistingItem } from '../UpdateExistingItem/UpdateExistingItem'
+import { Logger } from 'winston'
+import { ContentType, Dates, Result, Timestamps, Uuid } from '@standardnotes/domain-core'
+import { ItemHash } from '../../../Item/ItemHash'
+import { Item } from '../../../Item/Item'
+
+describe('SaveItems', () => {
+  let itemSaveValidator: ItemSaveValidatorInterface
+  let itemRepository: ItemRepositoryInterface
+  let timer: TimerInterface
+  let saveNewItem: SaveNewItem
+  let updateExistingItem: UpdateExistingItem
+  let logger: Logger
+  let itemHash1: ItemHash
+  let savedItem: Item
+
+  const createUseCase = () =>
+    new SaveItems(itemSaveValidator, itemRepository, timer, saveNewItem, updateExistingItem, logger)
+
+  beforeEach(() => {
+    itemSaveValidator = {} as jest.Mocked<ItemSaveValidatorInterface>
+    itemSaveValidator.validate = jest.fn().mockResolvedValue({ passed: true })
+
+    itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
+    itemRepository.findByUuid = jest.fn().mockResolvedValue(null)
+
+    timer = {} as jest.Mocked<TimerInterface>
+    timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123)
+
+    savedItem = 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()
+
+    saveNewItem = {} as jest.Mocked<SaveNewItem>
+    saveNewItem.execute = jest.fn().mockReturnValue(Result.ok(savedItem))
+
+    updateExistingItem = {} as jest.Mocked<UpdateExistingItem>
+    updateExistingItem.execute = jest.fn().mockResolvedValue(Result.ok(savedItem))
+
+    logger = {} as jest.Mocked<Logger>
+    logger.error = jest.fn()
+
+    itemHash1 = ItemHash.create({
+      uuid: 'item-uuid',
+      user_uuid: 'user-uuid',
+      content: 'content',
+      content_type: ContentType.TYPES.Note,
+      deleted: false,
+      auth_hash: 'auth-hash',
+      enc_item_key: 'enc-item-key',
+      items_key_id: 'items-key-id',
+      key_system_identifier: null,
+      shared_vault_uuid: null,
+      created_at: '2020-01-01T00:00:00.000Z',
+      created_at_timestamp: 123,
+      updated_at: '2020-01-01T00:00:00.000Z',
+      updated_at_timestamp: 123,
+    }).getValue()
+  })
+
+  it('should save new items', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      itemHashes: [itemHash1],
+      userUuid: 'user-uuid',
+      apiVersion: '1',
+      readOnlyAccess: false,
+      sessionUuid: 'session-uuid',
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+    expect(result.getValue().syncToken).toEqual('MjowLjAwMDEyNA==')
+    expect(saveNewItem.execute).toHaveBeenCalledWith({
+      itemHash: itemHash1,
+      userUuid: 'user-uuid',
+      sessionUuid: 'session-uuid',
+    })
+  })
+
+  it('should mark items as conflicts if saving new item fails', async () => {
+    const useCase = createUseCase()
+
+    saveNewItem.execute = jest.fn().mockResolvedValue(Result.fail('error'))
+
+    const result = await useCase.execute({
+      itemHashes: [itemHash1],
+      userUuid: 'user-uuid',
+      apiVersion: '1',
+      readOnlyAccess: false,
+      sessionUuid: 'session-uuid',
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+    expect(result.getValue().conflicts).toEqual([
+      {
+        unsavedItem: itemHash1,
+        type: 'uuid_conflict',
+      },
+    ])
+  })
+
+  it('should mark items as conflicts if saving new item throws an error', async () => {
+    const useCase = createUseCase()
+
+    saveNewItem.execute = jest.fn().mockRejectedValue(new Error('error'))
+
+    const result = await useCase.execute({
+      itemHashes: [itemHash1],
+      userUuid: 'user-uuid',
+      apiVersion: '1',
+      readOnlyAccess: false,
+      sessionUuid: 'session-uuid',
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+    expect(result.getValue().conflicts).toEqual([
+      {
+        unsavedItem: itemHash1,
+        type: 'uuid_conflict',
+      },
+    ])
+  })
+
+  it('should not save items if in read-only mode', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      itemHashes: [itemHash1],
+      userUuid: 'user-uuid',
+      apiVersion: '1',
+      readOnlyAccess: true,
+      sessionUuid: 'session-uuid',
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+    expect(saveNewItem.execute).not.toHaveBeenCalled()
+  })
+
+  it('should return conflicts if the items have not passed validation', async () => {
+    const useCase = createUseCase()
+
+    const conflict = {
+      unsavedItem: itemHash1,
+      type: 'conflict-type',
+    }
+    itemSaveValidator.validate = jest.fn().mockResolvedValue({ passed: false, conflict })
+
+    const result = await useCase.execute({
+      itemHashes: [itemHash1],
+      userUuid: 'user-uuid',
+      apiVersion: '1',
+      readOnlyAccess: false,
+      sessionUuid: 'session-uuid',
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+    expect(result.getValue().conflicts).toEqual([conflict])
+  })
+
+  it('should mark items as saved if they are skipped on validation', async () => {
+    const useCase = createUseCase()
+
+    itemSaveValidator.validate = jest.fn().mockResolvedValue({ passed: false, skipped: savedItem })
+
+    const result = await useCase.execute({
+      itemHashes: [itemHash1],
+      userUuid: 'user-uuid',
+      apiVersion: '1',
+      readOnlyAccess: false,
+      sessionUuid: 'session-uuid',
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+    expect(result.getValue().savedItems).toEqual([savedItem])
+  })
+
+  it('should update existing items', async () => {
+    const useCase = createUseCase()
+
+    itemRepository.findByUuid = jest.fn().mockResolvedValue(savedItem)
+
+    const result = await useCase.execute({
+      itemHashes: [itemHash1],
+      userUuid: 'user-uuid',
+      apiVersion: '1',
+      readOnlyAccess: false,
+      sessionUuid: 'session-uuid',
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+    expect(updateExistingItem.execute).toHaveBeenCalledWith({
+      itemHash: itemHash1,
+      existingItem: savedItem,
+      sessionUuid: 'session-uuid',
+    })
+  })
+
+  it('should mark items as conflicts if updating existing item fails', async () => {
+    const useCase = createUseCase()
+
+    itemRepository.findByUuid = jest.fn().mockResolvedValue(savedItem)
+    updateExistingItem.execute = jest.fn().mockResolvedValue(Result.fail('error'))
+
+    const result = await useCase.execute({
+      itemHashes: [itemHash1],
+      userUuid: 'user-uuid',
+      apiVersion: '1',
+      readOnlyAccess: false,
+      sessionUuid: 'session-uuid',
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+    expect(result.getValue().conflicts).toEqual([
+      {
+        unsavedItem: itemHash1,
+        type: 'uuid_conflict',
+      },
+    ])
+  })
+
+  it('should calculate the sync token based on existing and new items saved', async () => {
+    const useCase = createUseCase()
+
+    saveNewItem.execute = jest
+      .fn()
+      .mockResolvedValueOnce(Result.ok(savedItem))
+      .mockResolvedValueOnce(
+        Result.ok(
+          Item.create({
+            ...savedItem.props,
+            timestamps: Timestamps.create(100, 100).getValue(),
+          }).getValue(),
+        ),
+      )
+      .mockResolvedValueOnce(
+        Result.ok(
+          Item.create({
+            ...savedItem.props,
+            timestamps: Timestamps.create(159, 159).getValue(),
+          }).getValue(),
+        ),
+      )
+
+    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(),
+      ],
+      userUuid: 'user-uuid',
+      apiVersion: '2',
+      readOnlyAccess: false,
+      sessionUuid: 'session-uuid',
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+    expect(result.getValue().syncToken).toEqual('MjowLjAwMDE2')
+  })
+})

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

@@ -0,0 +1,145 @@
+import { Result, UseCaseInterface } from '@standardnotes/domain-core'
+
+import { SaveItemsResult } from './SaveItemsResult'
+import { SaveItemsDTO } from './SaveItemsDTO'
+import { Item } from '../../../Item/Item'
+import { ItemConflict } from '../../../Item/ItemConflict'
+import { ConflictType } from '@standardnotes/responses'
+import { Time, TimerInterface } from '@standardnotes/time'
+import { Logger } from 'winston'
+import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
+import { ItemSaveValidatorInterface } from '../../../Item/SaveValidator/ItemSaveValidatorInterface'
+import { SaveNewItem } from '../SaveNewItem/SaveNewItem'
+import { UpdateExistingItem } from '../UpdateExistingItem/UpdateExistingItem'
+
+export class SaveItems implements UseCaseInterface<SaveItemsResult> {
+  private readonly SYNC_TOKEN_VERSION = 2
+
+  constructor(
+    private itemSaveValidator: ItemSaveValidatorInterface,
+    private itemRepository: ItemRepositoryInterface,
+    private timer: TimerInterface,
+    private saveNewItem: SaveNewItem,
+    private updateExistingItem: UpdateExistingItem,
+    private logger: Logger,
+  ) {}
+
+  async execute(dto: SaveItemsDTO): Promise<Result<SaveItemsResult>> {
+    const savedItems: Array<Item> = []
+    const conflicts: Array<ItemConflict> = []
+
+    const lastUpdatedTimestamp = this.timer.getTimestampInMicroseconds()
+
+    for (const itemHash of dto.itemHashes) {
+      if (dto.readOnlyAccess) {
+        conflicts.push({
+          unsavedItem: itemHash,
+          type: ConflictType.ReadOnlyError,
+        })
+
+        continue
+      }
+
+      const existingItem = await this.itemRepository.findByUuid(itemHash.props.uuid)
+      const processingResult = await this.itemSaveValidator.validate({
+        userUuid: dto.userUuid,
+        apiVersion: dto.apiVersion,
+        itemHash,
+        existingItem,
+      })
+      if (!processingResult.passed) {
+        if (processingResult.conflict) {
+          conflicts.push(processingResult.conflict)
+        }
+        if (processingResult.skipped) {
+          savedItems.push(processingResult.skipped)
+        }
+
+        continue
+      }
+
+      if (existingItem) {
+        const udpatedItemOrError = await this.updateExistingItem.execute({
+          existingItem,
+          itemHash,
+          sessionUuid: dto.sessionUuid,
+        })
+        if (udpatedItemOrError.isFailed()) {
+          this.logger.error(
+            `[${dto.userUuid}] Updating item ${itemHash.props.uuid} failed. Error: ${udpatedItemOrError.getError()}`,
+          )
+
+          conflicts.push({
+            unsavedItem: itemHash,
+            type: ConflictType.UuidConflict,
+          })
+
+          continue
+        }
+        const updatedItem = udpatedItemOrError.getValue()
+
+        savedItems.push(updatedItem)
+      } else {
+        try {
+          const newItemOrError = await this.saveNewItem.execute({
+            userUuid: dto.userUuid,
+            itemHash,
+            sessionUuid: dto.sessionUuid,
+          })
+          if (newItemOrError.isFailed()) {
+            this.logger.error(
+              `[${dto.userUuid}] Saving item ${itemHash.props.uuid} failed. Error: ${newItemOrError.getError()}`,
+            )
+
+            conflicts.push({
+              unsavedItem: itemHash,
+              type: ConflictType.UuidConflict,
+            })
+
+            continue
+          }
+          const newItem = newItemOrError.getValue()
+
+          savedItems.push(newItem)
+        } catch (error) {
+          this.logger.error(
+            `[${dto.userUuid}] Saving item ${itemHash.props.uuid} failed. Error: ${(error as Error).message}`,
+          )
+
+          conflicts.push({
+            unsavedItem: itemHash,
+            type: ConflictType.UuidConflict,
+          })
+
+          continue
+        }
+      }
+    }
+
+    const syncToken = this.calculateSyncToken(lastUpdatedTimestamp, savedItems)
+
+    return Result.ok({
+      savedItems,
+      conflicts,
+      syncToken,
+    })
+  }
+
+  private calculateSyncToken(lastUpdatedTimestamp: number, savedItems: Array<Item>): string {
+    if (savedItems.length) {
+      const sortedItems = savedItems.sort((itemA: Item, itemB: Item) => {
+        return itemA.props.timestamps.updatedAt > itemB.props.timestamps.updatedAt ? 1 : -1
+      })
+      lastUpdatedTimestamp = sortedItems[sortedItems.length - 1].props.timestamps.updatedAt
+    }
+
+    const lastUpdatedTimestampWithMicrosecondPreventingSyncDoubles = lastUpdatedTimestamp + 1
+
+    return Buffer.from(
+      `${this.SYNC_TOKEN_VERSION}:${
+        lastUpdatedTimestampWithMicrosecondPreventingSyncDoubles / Time.MicrosecondsInASecond
+      }`,
+      'utf-8',
+    ).toString('base64')
+  }
+}

+ 2 - 2
packages/syncing-server/src/Domain/Item/SaveItemsDTO.ts → packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItemsDTO.ts

@@ -1,6 +1,6 @@
-import { ItemHash } from './ItemHash'
+import { ItemHash } from '../../../Item/ItemHash'
 
-export type SaveItemsDTO = {
+export interface SaveItemsDTO {
   itemHashes: ItemHash[]
   userUuid: string
   apiVersion: string

+ 8 - 0
packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItemsResult.ts

@@ -0,0 +1,8 @@
+import { Item } from '../../../Item/Item'
+import { ItemConflict } from '../../../Item/ItemConflict'
+
+export interface SaveItemsResult {
+  savedItems: Item[]
+  conflicts: ItemConflict[]
+  syncToken: string
+}

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

@@ -37,8 +37,11 @@ describe('SaveNewItem', () => {
       new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
     ).getValue()
 
-    itemHash1 = {
+    itemHash1 = ItemHash.create({
       uuid: '1-2-3',
+      user_uuid: '00000000-0000-0000-0000-000000000000',
+      key_system_identifier: null,
+      shared_vault_uuid: null,
       content: 'asdqwe1',
       content_type: ContentType.TYPES.Note,
       duplicate_of: null,
@@ -52,7 +55,7 @@ describe('SaveNewItem', () => {
         new Date(timeHelper.convertMicrosecondsToMilliseconds(item1.props.timestamps.updatedAt) + 1),
         'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
       ),
-    } as jest.Mocked<ItemHash>
+    }).getValue()
 
     itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
     itemRepository.save = jest.fn()
@@ -91,10 +94,13 @@ describe('SaveNewItem', () => {
   it('saves a new empty item', async () => {
     const useCase = createUseCase()
 
-    itemHash1.content = undefined
-    itemHash1.content_type = null
-    itemHash1.enc_item_key = undefined
-    itemHash1.items_key_id = undefined
+    itemHash1 = ItemHash.create({
+      ...itemHash1.props,
+      content: undefined,
+      content_type: null,
+      enc_item_key: undefined,
+      items_key_id: undefined,
+    }).getValue()
 
     const result = await useCase.execute({
       userUuid: '00000000-0000-0000-0000-000000000000',
@@ -109,8 +115,11 @@ describe('SaveNewItem', () => {
   it('saves a new item with given timestamps', async () => {
     const useCase = createUseCase()
 
-    itemHash1.created_at_timestamp = 123
-    itemHash1.updated_at_timestamp = 123
+    itemHash1 = ItemHash.create({
+      ...itemHash1.props,
+      created_at_timestamp: 123,
+      updated_at_timestamp: 123,
+    }).getValue()
 
     const result = await useCase.execute({
       userUuid: '00000000-0000-0000-0000-000000000000',
@@ -124,7 +133,10 @@ describe('SaveNewItem', () => {
   it('publishes a duplicate item synced event if the item is a duplicate', async () => {
     const useCase = createUseCase()
 
-    itemHash1.duplicate_of = '00000000-0000-0000-0000-000000000003'
+    itemHash1 = ItemHash.create({
+      ...itemHash1.props,
+      duplicate_of: '00000000-0000-0000-0000-000000000003',
+    }).getValue()
 
     const result = await useCase.execute({
       userUuid: '00000000-0000-0000-0000-000000000000',
@@ -140,7 +152,10 @@ describe('SaveNewItem', () => {
   it('publishes a item revision creation requested event if the item is a revision', async () => {
     const useCase = createUseCase()
 
-    itemHash1.updated_at = '2021-03-19T17:17:13.241Z'
+    itemHash1 = ItemHash.create({
+      ...itemHash1.props,
+      updated_at: '2021-03-19T17:17:13.241Z',
+    }).getValue()
 
     const result = await useCase.execute({
       userUuid: '00000000-0000-0000-0000-000000000000',
@@ -199,7 +214,10 @@ describe('SaveNewItem', () => {
   it('returns a failure if the content type is invalid', async () => {
     const useCase = createUseCase()
 
-    itemHash1.content_type = 'invalid'
+    itemHash1 = ItemHash.create({
+      ...itemHash1.props,
+      content_type: 'invalid',
+    }).getValue()
 
     const result = await useCase.execute({
       userUuid: '00000000-0000-0000-0000-000000000000',
@@ -213,7 +231,10 @@ describe('SaveNewItem', () => {
   it('returns a failure if the duplicate uuid is invalid', async () => {
     const useCase = createUseCase()
 
-    itemHash1.duplicate_of = 'invalid'
+    itemHash1 = ItemHash.create({
+      ...itemHash1.props,
+      duplicate_of: 'invalid',
+    }).getValue()
 
     const result = await useCase.execute({
       userUuid: '00000000-0000-0000-0000-000000000000',

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

@@ -38,15 +38,15 @@ export class SaveNewItem implements UseCaseInterface<Item> {
     }
     const userUuid = userUuidOrError.getValue()
 
-    const contentTypeOrError = ContentType.create(dto.itemHash.content_type)
+    const contentTypeOrError = ContentType.create(dto.itemHash.props.content_type)
     if (contentTypeOrError.isFailed()) {
       return Result.fail(contentTypeOrError.getError())
     }
     const contentType = contentTypeOrError.getValue()
 
     let duplicateOf = null
-    if (dto.itemHash.duplicate_of) {
-      const duplicateOfOrError = Uuid.create(dto.itemHash.duplicate_of)
+    if (dto.itemHash.props.duplicate_of) {
+      const duplicateOfOrError = Uuid.create(dto.itemHash.props.duplicate_of)
       if (duplicateOfOrError.isFailed()) {
         return Result.fail(duplicateOfOrError.getError())
       }
@@ -58,12 +58,12 @@ export class SaveNewItem implements UseCaseInterface<Item> {
 
     let createdAtDate = nowDate
     let createdAtTimestamp = now
-    if (dto.itemHash.created_at_timestamp) {
-      createdAtTimestamp = dto.itemHash.created_at_timestamp
+    if (dto.itemHash.props.created_at_timestamp) {
+      createdAtTimestamp = dto.itemHash.props.created_at_timestamp
       createdAtDate = this.timer.convertMicrosecondsToDate(createdAtTimestamp)
-    } else if (dto.itemHash.created_at) {
-      createdAtTimestamp = this.timer.convertStringDateToMicroseconds(dto.itemHash.created_at)
-      createdAtDate = this.timer.convertStringDateToDate(dto.itemHash.created_at)
+    } else if (dto.itemHash.props.created_at) {
+      createdAtTimestamp = this.timer.convertStringDateToMicroseconds(dto.itemHash.props.created_at)
+      createdAtDate = this.timer.convertStringDateToDate(dto.itemHash.props.created_at)
     }
 
     const datesOrError = Dates.create(createdAtDate, nowDate)
@@ -81,18 +81,18 @@ export class SaveNewItem implements UseCaseInterface<Item> {
     const itemOrError = Item.create(
       {
         updatedWithSession,
-        content: dto.itemHash.content ?? null,
+        content: dto.itemHash.props.content ?? null,
         userUuid,
         contentType,
-        encItemKey: dto.itemHash.enc_item_key ?? null,
-        authHash: dto.itemHash.auth_hash ?? null,
-        itemsKeyId: dto.itemHash.items_key_id ?? null,
+        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.deleted ?? false,
+        deleted: dto.itemHash.props.deleted ?? false,
         dates,
         timestamps,
       },
-      new UniqueEntityId(dto.itemHash.uuid),
+      new UniqueEntityId(dto.itemHash.props.uuid),
     )
     if (itemOrError.isFailed()) {
       return Result.fail(itemOrError.getError())

+ 96 - 38
packages/syncing-server/src/Domain/UseCase/Syncing/SyncItems/SyncItems.spec.ts

@@ -3,19 +3,23 @@ import 'reflect-metadata'
 import { ApiVersion } from '../../../Api/ApiVersion'
 import { Item } from '../../../Item/Item'
 import { ItemHash } from '../../../Item/ItemHash'
-import { ItemServiceInterface } from '../../../Item/ItemServiceInterface'
 
 import { SyncItems } from './SyncItems'
-import { ContentType, Dates, Timestamps, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
+import { ContentType, Dates, Result, Timestamps, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
+import { GetItems } from '../GetItems/GetItems'
+import { SaveItems } from '../SaveItems/SaveItems'
+import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
 
 describe('SyncItems', () => {
-  let itemService: ItemServiceInterface
+  let getItemsUseCase: GetItems
+  let saveItemsUseCase: SaveItems
+  let itemRepository: ItemRepositoryInterface
   let item1: Item
   let item2: Item
   let item3: Item
   let itemHash: ItemHash
 
-  const createUseCase = () => new SyncItems(itemService)
+  const createUseCase = () => new SyncItems(itemRepository, getItemsUseCase, saveItemsUseCase)
 
   beforeEach(() => {
     item1 = Item.create(
@@ -67,8 +71,11 @@ describe('SyncItems', () => {
       new UniqueEntityId('00000000-0000-0000-0000-000000000003'),
     ).getValue()
 
-    itemHash = {
+    itemHash = ItemHash.create({
       uuid: '2-3-4',
+      user_uuid: '1-2-3',
+      key_system_identifier: null,
+      shared_vault_uuid: null,
       content: 'asdqwe',
       content_type: ContentType.TYPES.Note,
       duplicate_of: null,
@@ -76,19 +83,27 @@ describe('SyncItems', () => {
       items_key_id: 'asdasd',
       created_at: '2021-02-19T11:35:45.655Z',
       updated_at: '2021-03-25T09:37:37.944Z',
-    }
+    }).getValue()
 
-    itemService = {} as jest.Mocked<ItemServiceInterface>
-    itemService.getItems = jest.fn().mockReturnValue({
-      items: [item1],
-      cursorToken: 'asdzxc',
-    })
-    itemService.saveItems = jest.fn().mockReturnValue({
-      savedItems: [item2],
-      conflicts: [],
-      syncToken: 'qwerty',
-    })
-    itemService.frontLoadKeysItemsToTop = jest.fn().mockReturnValue([item3, item1])
+    getItemsUseCase = {} as jest.Mocked<GetItems>
+    getItemsUseCase.execute = jest.fn().mockReturnValue(
+      Result.ok({
+        items: [item1],
+        cursorToken: 'asdzxc',
+      }),
+    )
+
+    saveItemsUseCase = {} as jest.Mocked<SaveItems>
+    saveItemsUseCase.execute = jest.fn().mockReturnValue(
+      Result.ok({
+        savedItems: [item2],
+        conflicts: [],
+        syncToken: 'qwerty',
+      }),
+    )
+
+    itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
+    itemRepository.findAll = jest.fn().mockReturnValue([item3, item1])
   })
 
   it('should sync items', async () => {
@@ -113,15 +128,14 @@ describe('SyncItems', () => {
       syncToken: 'qwerty',
     })
 
-    expect(itemService.frontLoadKeysItemsToTop).not.toHaveBeenCalled()
-    expect(itemService.getItems).toHaveBeenCalledWith({
+    expect(getItemsUseCase.execute).toHaveBeenCalledWith({
       contentType: 'Note',
       cursorToken: 'bar',
       limit: 10,
       syncToken: 'foo',
       userUuid: '1-2-3',
     })
-    expect(itemService.saveItems).toHaveBeenCalledWith({
+    expect(saveItemsUseCase.execute).toHaveBeenCalledWith({
       itemHashes: [itemHash],
       userUuid: '1-2-3',
       apiVersion: '20200115',
@@ -152,25 +166,29 @@ describe('SyncItems', () => {
   })
 
   it('should sync items and return filtered out sync conflicts for consecutive sync operations', async () => {
-    itemService.getItems = jest.fn().mockReturnValue({
-      items: [item1, item2],
-      cursorToken: 'asdzxc',
-    })
+    getItemsUseCase.execute = jest.fn().mockReturnValue(
+      Result.ok({
+        items: [item1, item2],
+        cursorToken: 'asdzxc',
+      }),
+    )
 
-    itemService.saveItems = jest.fn().mockReturnValue({
-      savedItems: [],
-      conflicts: [
-        {
-          serverItem: item2,
-          type: 'sync_conflict',
-        },
-        {
-          serverItem: undefined,
-          type: 'sync_conflict',
-        },
-      ],
-      syncToken: 'qwerty',
-    })
+    saveItemsUseCase.execute = jest.fn().mockReturnValue(
+      Result.ok({
+        savedItems: [],
+        conflicts: [
+          {
+            serverItem: item2,
+            type: 'sync_conflict',
+          },
+          {
+            serverItem: undefined,
+            type: 'sync_conflict',
+          },
+        ],
+        syncToken: 'qwerty',
+      }),
+    )
 
     const result = await createUseCase().execute({
       userUuid: '1-2-3',
@@ -203,4 +221,44 @@ describe('SyncItems', () => {
       syncToken: 'qwerty',
     })
   })
+
+  it('should return error if get items fails', async () => {
+    getItemsUseCase.execute = jest.fn().mockReturnValue(Result.fail('error'))
+
+    const result = await createUseCase().execute({
+      userUuid: '1-2-3',
+      itemHashes: [itemHash],
+      computeIntegrityHash: false,
+      syncToken: 'foo',
+      readOnlyAccess: false,
+      sessionUuid: '2-3-4',
+      cursorToken: 'bar',
+      limit: 10,
+      contentType: 'Note',
+      apiVersion: ApiVersion.v20200115,
+      snjsVersion: '1.2.3',
+    })
+
+    expect(result.isFailed()).toBeTruthy()
+  })
+
+  it('should return error if save items fails', async () => {
+    saveItemsUseCase.execute = jest.fn().mockReturnValue(Result.fail('error'))
+
+    const result = await createUseCase().execute({
+      userUuid: '1-2-3',
+      itemHashes: [itemHash],
+      computeIntegrityHash: false,
+      syncToken: 'foo',
+      readOnlyAccess: false,
+      sessionUuid: '2-3-4',
+      cursorToken: 'bar',
+      limit: 10,
+      contentType: 'Note',
+      apiVersion: ApiVersion.v20200115,
+      snjsVersion: '1.2.3',
+    })
+
+    expect(result.isFailed()).toBeTruthy()
+  })
 })

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

@@ -1,34 +1,48 @@
-import { Result, UseCaseInterface } from '@standardnotes/domain-core'
+import { ContentType, Result, UseCaseInterface } from '@standardnotes/domain-core'
 
 import { Item } from '../../../Item/Item'
 import { ItemConflict } from '../../../Item/ItemConflict'
-import { ItemServiceInterface } from '../../../Item/ItemServiceInterface'
 import { SyncItemsDTO } from './SyncItemsDTO'
 import { SyncItemsResponse } from './SyncItemsResponse'
+import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
+import { GetItems } from '../GetItems/GetItems'
+import { SaveItems } from '../SaveItems/SaveItems'
 
 export class SyncItems implements UseCaseInterface<SyncItemsResponse> {
-  constructor(private itemService: ItemServiceInterface) {}
+  constructor(
+    private itemRepository: ItemRepositoryInterface,
+    private getItemsUseCase: GetItems,
+    private saveItemsUseCase: SaveItems,
+  ) {}
 
   async execute(dto: SyncItemsDTO): Promise<Result<SyncItemsResponse>> {
-    const getItemsResult = await this.itemService.getItems({
+    const getItemsResultOrError = await this.getItemsUseCase.execute({
       userUuid: dto.userUuid,
       syncToken: dto.syncToken,
       cursorToken: dto.cursorToken,
       limit: dto.limit,
       contentType: dto.contentType,
     })
+    if (getItemsResultOrError.isFailed()) {
+      return Result.fail(getItemsResultOrError.getError())
+    }
+    const getItemsResult = getItemsResultOrError.getValue()
 
-    const saveItemsResult = await this.itemService.saveItems({
+    const saveItemsResultOrError = await this.saveItemsUseCase.execute({
       itemHashes: dto.itemHashes,
       userUuid: dto.userUuid,
       apiVersion: dto.apiVersion,
       readOnlyAccess: dto.readOnlyAccess,
       sessionUuid: dto.sessionUuid,
     })
+    if (saveItemsResultOrError.isFailed()) {
+      return Result.fail(saveItemsResultOrError.getError())
+    }
+    const saveItemsResult = saveItemsResultOrError.getValue()
 
     let retrievedItems = this.filterOutSyncConflictsForConsecutiveSyncs(getItemsResult.items, saveItemsResult.conflicts)
     if (this.isFirstSync(dto)) {
-      retrievedItems = await this.itemService.frontLoadKeysItemsToTop(dto.userUuid, retrievedItems)
+      retrievedItems = await this.frontLoadKeysItemsToTop(dto.userUuid, retrievedItems)
     }
 
     const syncResponse: SyncItemsResponse = {
@@ -59,4 +73,23 @@ export class SyncItems implements UseCaseInterface<SyncItemsResponse> {
 
     return retrievedItems.filter((item: Item) => syncConflictIds.indexOf(item.id.toString()) === -1)
   }
+
+  private async frontLoadKeysItemsToTop(userUuid: string, retrievedItems: Array<Item>): Promise<Array<Item>> {
+    const itemsKeys = await this.itemRepository.findAll({
+      userUuid,
+      contentType: ContentType.TYPES.ItemsKey,
+      sortBy: 'updated_at_timestamp',
+      sortOrder: 'ASC',
+    })
+
+    const retrievedItemsIds: Array<string> = retrievedItems.map((item: Item) => item.id.toString())
+
+    itemsKeys.forEach((itemKey: Item) => {
+      if (retrievedItemsIds.indexOf(itemKey.id.toString()) === -1) {
+        retrievedItems.unshift(itemKey)
+      }
+    })
+
+    return retrievedItems
+  }
 }

+ 29 - 26
packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem.spec.ts

@@ -37,8 +37,11 @@ describe('UpdateExistingItem', () => {
       new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
     ).getValue()
 
-    itemHash1 = {
+    itemHash1 = ItemHash.create({
       uuid: '1-2-3',
+      user_uuid: '00000000-0000-0000-0000-000000000000',
+      key_system_identifier: null,
+      shared_vault_uuid: null,
       content: 'asdqwe1',
       content_type: ContentType.TYPES.Note,
       duplicate_of: null,
@@ -53,7 +56,7 @@ describe('UpdateExistingItem', () => {
         new Date(timeHelper.convertMicrosecondsToMilliseconds(item1.props.timestamps.updatedAt) + 1),
         'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
       ),
-    } as jest.Mocked<ItemHash>
+    }).getValue()
 
     itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
     itemRepository.save = jest.fn()
@@ -107,10 +110,10 @@ describe('UpdateExistingItem', () => {
 
     const result = await useCase.execute({
       existingItem: item1,
-      itemHash: {
-        ...itemHash1,
+      itemHash: ItemHash.create({
+        ...itemHash1.props,
         content_type: 'invalid',
-      },
+      }).getValue(),
       sessionUuid: '00000000-0000-0000-0000-000000000000',
     })
 
@@ -122,10 +125,10 @@ describe('UpdateExistingItem', () => {
 
     const result = await useCase.execute({
       existingItem: item1,
-      itemHash: {
-        ...itemHash1,
+      itemHash: ItemHash.create({
+        ...itemHash1.props,
         deleted: true,
-      },
+      }).getValue(),
       sessionUuid: '00000000-0000-0000-0000-000000000000',
     })
 
@@ -144,10 +147,10 @@ describe('UpdateExistingItem', () => {
 
     const result = await useCase.execute({
       existingItem: item1,
-      itemHash: {
-        ...itemHash1,
+      itemHash: ItemHash.create({
+        ...itemHash1.props,
         duplicate_of: '00000000-0000-0000-0000-000000000001',
-      },
+      }).getValue(),
       sessionUuid: '00000000-0000-0000-0000-000000000000',
     })
 
@@ -161,10 +164,10 @@ describe('UpdateExistingItem', () => {
 
     const result = await useCase.execute({
       existingItem: item1,
-      itemHash: {
-        ...itemHash1,
+      itemHash: ItemHash.create({
+        ...itemHash1.props,
         duplicate_of: 'invalid-uuid',
-      },
+      }).getValue(),
       sessionUuid: '00000000-0000-0000-0000-000000000000',
     })
 
@@ -176,11 +179,11 @@ describe('UpdateExistingItem', () => {
 
     const result = await useCase.execute({
       existingItem: item1,
-      itemHash: {
-        ...itemHash1,
+      itemHash: ItemHash.create({
+        ...itemHash1.props,
         updated_at_timestamp: 123,
         created_at_timestamp: 123,
-      },
+      }).getValue(),
       sessionUuid: '00000000-0000-0000-0000-000000000000',
     })
 
@@ -193,11 +196,11 @@ describe('UpdateExistingItem', () => {
 
     const result = await useCase.execute({
       existingItem: item1,
-      itemHash: {
-        ...itemHash1,
+      itemHash: ItemHash.create({
+        ...itemHash1.props,
         created_at: undefined,
         created_at_timestamp: undefined,
-      },
+      }).getValue(),
       sessionUuid: '00000000-0000-0000-0000-000000000000',
     })
 
@@ -214,11 +217,11 @@ describe('UpdateExistingItem', () => {
 
     const result = await useCase.execute({
       existingItem: item1,
-      itemHash: {
-        ...itemHash1,
+      itemHash: ItemHash.create({
+        ...itemHash1.props,
         created_at_timestamp: 123,
         updated_at_timestamp: 123,
-      },
+      }).getValue(),
       sessionUuid: '00000000-0000-0000-0000-000000000000',
     })
 
@@ -237,11 +240,11 @@ describe('UpdateExistingItem', () => {
 
     const result = await useCase.execute({
       existingItem: item1,
-      itemHash: {
-        ...itemHash1,
+      itemHash: ItemHash.create({
+        ...itemHash1.props,
         created_at_timestamp: 123,
         updated_at_timestamp: 123,
-      },
+      }).getValue(),
       sessionUuid: '00000000-0000-0000-0000-000000000000',
     })
 

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

@@ -27,12 +27,12 @@ export class UpdateExistingItem implements UseCaseInterface<Item> {
     }
     dto.existingItem.props.updatedWithSession = sessionUuid
 
-    if (dto.itemHash.content) {
-      dto.existingItem.props.content = dto.itemHash.content
+    if (dto.itemHash.props.content) {
+      dto.existingItem.props.content = dto.itemHash.props.content
     }
 
-    if (dto.itemHash.content_type) {
-      const contentTypeOrError = ContentType.create(dto.itemHash.content_type)
+    if (dto.itemHash.props.content_type) {
+      const contentTypeOrError = ContentType.create(dto.itemHash.props.content_type)
       if (contentTypeOrError.isFailed()) {
         return Result.fail(contentTypeOrError.getError())
       }
@@ -40,13 +40,13 @@ export class UpdateExistingItem implements UseCaseInterface<Item> {
       dto.existingItem.props.contentType = contentType
     }
 
-    if (dto.itemHash.deleted !== undefined) {
-      dto.existingItem.props.deleted = dto.itemHash.deleted
+    if (dto.itemHash.props.deleted !== undefined) {
+      dto.existingItem.props.deleted = dto.itemHash.props.deleted
     }
 
     let wasMarkedAsDuplicate = false
-    if (dto.itemHash.duplicate_of) {
-      const duplicateOfOrError = Uuid.create(dto.itemHash.duplicate_of)
+    if (dto.itemHash.props.duplicate_of) {
+      const duplicateOfOrError = Uuid.create(dto.itemHash.props.duplicate_of)
       if (duplicateOfOrError.isFailed()) {
         return Result.fail(duplicateOfOrError.getError())
       }
@@ -54,14 +54,14 @@ export class UpdateExistingItem implements UseCaseInterface<Item> {
       dto.existingItem.props.duplicateOf = duplicateOfOrError.getValue()
     }
 
-    if (dto.itemHash.auth_hash) {
-      dto.existingItem.props.authHash = dto.itemHash.auth_hash
+    if (dto.itemHash.props.auth_hash) {
+      dto.existingItem.props.authHash = dto.itemHash.props.auth_hash
     }
-    if (dto.itemHash.enc_item_key) {
-      dto.existingItem.props.encItemKey = dto.itemHash.enc_item_key
+    if (dto.itemHash.props.enc_item_key) {
+      dto.existingItem.props.encItemKey = dto.itemHash.props.enc_item_key
     }
-    if (dto.itemHash.items_key_id) {
-      dto.existingItem.props.itemsKeyId = dto.itemHash.items_key_id
+    if (dto.itemHash.props.items_key_id) {
+      dto.existingItem.props.itemsKeyId = dto.itemHash.props.items_key_id
     }
 
     const updatedAtTimestamp = this.timer.getTimestampInMicroseconds()
@@ -72,12 +72,12 @@ export class UpdateExistingItem implements UseCaseInterface<Item> {
 
     let createdAtTimestamp: number
     let createdAtDate: Date
-    if (dto.itemHash.created_at_timestamp) {
-      createdAtTimestamp = dto.itemHash.created_at_timestamp
+    if (dto.itemHash.props.created_at_timestamp) {
+      createdAtTimestamp = dto.itemHash.props.created_at_timestamp
       createdAtDate = this.timer.convertMicrosecondsToDate(createdAtTimestamp)
-    } else if (dto.itemHash.created_at) {
-      createdAtTimestamp = this.timer.convertStringDateToMicroseconds(dto.itemHash.created_at)
-      createdAtDate = this.timer.convertStringDateToDate(dto.itemHash.created_at)
+    } else if (dto.itemHash.props.created_at) {
+      createdAtTimestamp = this.timer.convertStringDateToMicroseconds(dto.itemHash.props.created_at)
+      createdAtDate = this.timer.convertStringDateToDate(dto.itemHash.props.created_at)
     } else {
       return Result.fail('Created at timestamp is required.')
     }
@@ -96,7 +96,7 @@ export class UpdateExistingItem implements UseCaseInterface<Item> {
 
     dto.existingItem.props.contentSize = Buffer.byteLength(JSON.stringify(dto.existingItem))
 
-    if (dto.itemHash.deleted === true) {
+    if (dto.itemHash.props.deleted === true) {
       dto.existingItem.props.deleted = true
       dto.existingItem.props.content = null
       dto.existingItem.props.contentSize = 0

+ 16 - 2
packages/syncing-server/src/Infra/InversifyExpressUtils/HomeServer/HomeServerItemsController.ts

@@ -10,6 +10,7 @@ import { ApiVersion } from '../../../Domain/Api/ApiVersion'
 import { SyncItems } from '../../../Domain/UseCase/Syncing/SyncItems/SyncItems'
 import { HttpStatusCode } from '@standardnotes/responses'
 import { ItemHttpRepresentation } from '../../../Mapping/Http/ItemHttpRepresentation'
+import { ItemHash } from '../../../Domain/Item/ItemHash'
 
 export class HomeServerItemsController extends BaseHttpController {
   constructor(
@@ -30,9 +31,22 @@ export class HomeServerItemsController extends BaseHttpController {
   }
 
   async sync(request: Request, response: Response): Promise<results.JsonResult> {
-    let itemHashes = []
+    const itemHashes: ItemHash[] = []
     if ('items' in request.body) {
-      itemHashes = request.body.items
+      for (const itemHashInput of request.body.items) {
+        const itemHashOrError = ItemHash.create({
+          ...itemHashInput,
+          user_uuid: response.locals.user.uuid,
+          key_system_identifier: itemHashInput.key_system_identifier ?? null,
+          shared_vault_uuid: itemHashInput.shared_vault_uuid ?? null,
+        })
+
+        if (itemHashOrError.isFailed()) {
+          return this.json({ error: { message: itemHashOrError.getError() } }, HttpStatusCode.BadRequest)
+        }
+
+        itemHashes.push(itemHashOrError.getValue())
+      }
     }
 
     const syncResult = await this.syncItems.execute({

+ 0 - 238
packages/syncing-server/src/Infra/InversifyExpressUtils/InversifyExpressItemsController.spec.ts

@@ -1,238 +0,0 @@
-import 'reflect-metadata'
-
-import * as express from 'express'
-import { ContentType, MapperInterface, Result } from '@standardnotes/domain-core'
-import { results } from 'inversify-express-utils'
-
-import { InversifyExpressItemsController } from './InversifyExpressItemsController'
-import { Item } from '../../Domain/Item/Item'
-import { ApiVersion } from '../../Domain/Api/ApiVersion'
-import { SyncResponse20200115 } from '../../Domain/Item/SyncResponse/SyncResponse20200115'
-import { SyncResponseFactoryInterface } from '../../Domain/Item/SyncResponse/SyncResponseFactoryInterface'
-import { SyncResponseFactoryResolverInterface } from '../../Domain/Item/SyncResponse/SyncResponseFactoryResolverInterface'
-import { CheckIntegrity } from '../../Domain/UseCase/Syncing/CheckIntegrity/CheckIntegrity'
-import { GetItem } from '../../Domain/UseCase/Syncing/GetItem/GetItem'
-import { SyncItems } from '../../Domain/UseCase/Syncing/SyncItems/SyncItems'
-import { ItemHttpRepresentation } from '../../Mapping/Http/ItemHttpRepresentation'
-
-describe('InversifyExpressItemsController', () => {
-  let syncItems: SyncItems
-  let checkIntegrity: CheckIntegrity
-  let getItem: GetItem
-  let mapper: MapperInterface<Item, ItemHttpRepresentation>
-  let request: express.Request
-  let response: express.Response
-  let syncResponceFactoryResolver: SyncResponseFactoryResolverInterface
-  let syncResponseFactory: SyncResponseFactoryInterface
-  let syncResponse: SyncResponse20200115
-
-  const createController = () =>
-    new InversifyExpressItemsController(syncItems, checkIntegrity, getItem, mapper, syncResponceFactoryResolver)
-
-  beforeEach(() => {
-    mapper = {} as jest.Mocked<MapperInterface<Item, ItemHttpRepresentation>>
-    mapper.toProjection = jest.fn().mockReturnValue({ foo: 'bar' })
-
-    syncItems = {} as jest.Mocked<SyncItems>
-    syncItems.execute = jest.fn().mockReturnValue(Result.ok({ foo: 'bar' }))
-
-    checkIntegrity = {} as jest.Mocked<CheckIntegrity>
-    checkIntegrity.execute = jest.fn().mockReturnValue(Result.ok([{ uuid: '1-2-3', updated_at_timestamp: 2 }]))
-
-    getItem = {} as jest.Mocked<GetItem>
-    getItem.execute = jest.fn().mockReturnValue(Result.ok({} as jest.Mocked<Item>))
-
-    request = {
-      headers: {},
-      body: {},
-      params: {},
-    } as jest.Mocked<express.Request>
-
-    request.body.api = ApiVersion.v20200115
-    request.body.sync_token = 'MjoxNjE3MTk1MzQyLjc1ODEyMTc='
-    request.body.limit = 150
-    request.body.compute_integrity = false
-    request.headers['user-agent'] = 'Google Chrome'
-    request.body.items = [
-      {
-        content: 'test',
-        content_type: ContentType.TYPES.Note,
-        created_at: '2021-02-19T11:35:45.655Z',
-        deleted: false,
-        duplicate_of: null,
-        enc_item_key: 'test',
-        items_key_id: 'test',
-        updated_at: '2021-02-19T11:35:45.655Z',
-        uuid: '1-2-3',
-      },
-    ]
-
-    response = {
-      locals: {},
-    } as jest.Mocked<express.Response>
-    response.locals.user = {
-      uuid: '123',
-    }
-    response.locals.freeUser = false
-
-    syncResponse = {} as jest.Mocked<SyncResponse20200115>
-
-    syncResponseFactory = {} as jest.Mocked<SyncResponseFactoryInterface>
-    syncResponseFactory.createResponse = jest.fn().mockReturnValue(syncResponse)
-
-    syncResponceFactoryResolver = {} as jest.Mocked<SyncResponseFactoryResolverInterface>
-    syncResponceFactoryResolver.resolveSyncResponseFactoryVersion = jest.fn().mockReturnValue(syncResponseFactory)
-  })
-
-  it('should get a single item', async () => {
-    request.params.uuid = '1-2-3'
-    const httpResponse = <results.JsonResult>await createController().getSingleItem(request, response)
-    const result = await httpResponse.executeAsync()
-
-    expect(getItem.execute).toHaveBeenCalledWith({
-      itemUuid: '1-2-3',
-      userUuid: '123',
-    })
-
-    expect(result.statusCode).toEqual(200)
-  })
-
-  it('should return 404 on a missing single item', async () => {
-    request.params.uuid = '1-2-3'
-    getItem.execute = jest.fn().mockReturnValue(Result.fail('Oops'))
-
-    const httpResponse = <results.NotFoundResult>await createController().getSingleItem(request, response)
-    const result = await httpResponse.executeAsync()
-
-    expect(getItem.execute).toHaveBeenCalledWith({
-      itemUuid: '1-2-3',
-      userUuid: '123',
-    })
-
-    expect(result.statusCode).toEqual(404)
-  })
-
-  it('should check items integrity', async () => {
-    request.body.integrityPayloads = [
-      {
-        uuid: '1-2-3',
-        updated_at_timestamp: 1,
-      },
-    ]
-
-    const httpResponse = <results.JsonResult>await createController().checkItemsIntegrity(request, response)
-    const result = await httpResponse.executeAsync()
-
-    expect(checkIntegrity.execute).toHaveBeenCalledWith({
-      integrityPayloads: [
-        {
-          updated_at_timestamp: 1,
-          uuid: '1-2-3',
-        },
-      ],
-      userUuid: '123',
-      freeUser: false,
-    })
-
-    expect(result.statusCode).toEqual(200)
-    expect(await result.content.readAsStringAsync()).toEqual(
-      '{"mismatches":[{"uuid":"1-2-3","updated_at_timestamp":2}]}',
-    )
-  })
-
-  it('should check items integrity with missing request parameter', async () => {
-    const httpResponse = <results.JsonResult>await createController().checkItemsIntegrity(request, response)
-    const result = await httpResponse.executeAsync()
-
-    expect(checkIntegrity.execute).toHaveBeenCalledWith({
-      integrityPayloads: [],
-      userUuid: '123',
-      freeUser: false,
-    })
-
-    expect(result.statusCode).toEqual(200)
-    expect(await result.content.readAsStringAsync()).toEqual(
-      '{"mismatches":[{"uuid":"1-2-3","updated_at_timestamp":2}]}',
-    )
-  })
-
-  it('should sync items', async () => {
-    const httpResponse = <results.JsonResult>await createController().sync(request, response)
-    const result = await httpResponse.executeAsync()
-
-    expect(syncItems.execute).toHaveBeenCalledWith({
-      apiVersion: '20200115',
-      computeIntegrityHash: false,
-      itemHashes: [
-        {
-          content: 'test',
-          content_type: 'Note',
-          created_at: '2021-02-19T11:35:45.655Z',
-          deleted: false,
-          duplicate_of: null,
-          enc_item_key: 'test',
-          items_key_id: 'test',
-          updated_at: '2021-02-19T11:35:45.655Z',
-          uuid: '1-2-3',
-        },
-      ],
-      limit: 150,
-      syncToken: 'MjoxNjE3MTk1MzQyLjc1ODEyMTc=',
-      userUuid: '123',
-      sessionUuid: null,
-    })
-
-    expect(result.statusCode).toEqual(200)
-  })
-
-  it('should sync items with defaulting API version if none specified', async () => {
-    delete request.body.api
-
-    const httpResponse = <results.JsonResult>await createController().sync(request, response)
-    const result = await httpResponse.executeAsync()
-
-    expect(syncItems.execute).toHaveBeenCalledWith({
-      apiVersion: '20161215',
-      computeIntegrityHash: false,
-      itemHashes: [
-        {
-          content: 'test',
-          content_type: 'Note',
-          created_at: '2021-02-19T11:35:45.655Z',
-          deleted: false,
-          duplicate_of: null,
-          enc_item_key: 'test',
-          items_key_id: 'test',
-          updated_at: '2021-02-19T11:35:45.655Z',
-          uuid: '1-2-3',
-        },
-      ],
-      limit: 150,
-      syncToken: 'MjoxNjE3MTk1MzQyLjc1ODEyMTc=',
-      userUuid: '123',
-      sessionUuid: null,
-    })
-
-    expect(result.statusCode).toEqual(200)
-  })
-
-  it('should sync items with no incoming items in request', async () => {
-    response.locals.session = { uuid: '2-3-4' }
-    delete request.body.items
-
-    const httpResponse = <results.JsonResult>await createController().sync(request, response)
-    const result = await httpResponse.executeAsync()
-
-    expect(syncItems.execute).toHaveBeenCalledWith({
-      apiVersion: '20200115',
-      computeIntegrityHash: false,
-      itemHashes: [],
-      limit: 150,
-      syncToken: 'MjoxNjE3MTk1MzQyLjc1ODEyMTc=',
-      userUuid: '123',
-      sessionUuid: '2-3-4',
-    })
-
-    expect(result.statusCode).toEqual(200)
-  })
-})

+ 45 - 0
packages/syncing-server/src/Infra/TypeORM/TypeORMSharedVaultItem.ts

@@ -0,0 +1,45 @@
+import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
+
+@Entity({ name: 'shared_vault_items' })
+export class TypeORMSharedVaultItem {
+  @PrimaryGeneratedColumn('uuid')
+  declare uuid: string
+
+  @Column({
+    name: 'shared_vault_uuid',
+    length: 36,
+  })
+  declare sharedVaultUuid: string
+
+  @Column({
+    name: 'item_uuid',
+    length: 36,
+  })
+  declare itemUuid: string
+
+  @Column({
+    name: 'key_system_identifier',
+    type: 'varchar',
+    length: 36,
+  })
+  declare keySystemIdentifier: string
+
+  @Column({
+    name: 'last_edited_by',
+    type: 'varchar',
+    length: 36,
+  })
+  declare lastEditedBy: string
+
+  @Column({
+    name: 'created_at_timestamp',
+    type: 'bigint',
+  })
+  declare createdAtTimestamp: number
+
+  @Column({
+    name: 'updated_at_timestamp',
+    type: 'bigint',
+  })
+  declare updatedAtTimestamp: number
+}

+ 30 - 0
packages/syncing-server/src/Infra/TypeORM/TypeORMSharedVaultItemRepository.ts

@@ -0,0 +1,30 @@
+import { Repository } from 'typeorm'
+import { MapperInterface, UniqueEntityId } from '@standardnotes/domain-core'
+
+import { SharedVaultItem } from '../../Domain/SharedVault/Item/SharedVaultItem'
+import { SharedVaultItemRepositoryInterface } from '../../Domain/SharedVault/Item/SharedVaultItemRepositoryInterface'
+import { TypeORMSharedVaultItem } from './TypeORMSharedVaultItem'
+
+export class TypeORMSharedVaultItemRepository implements SharedVaultItemRepositoryInterface {
+  constructor(
+    private ormRepository: Repository<TypeORMSharedVaultItem>,
+    private mapper: MapperInterface<SharedVaultItem, TypeORMSharedVaultItem>,
+  ) {}
+
+  async findBySharedVaultId(sharedVaultId: UniqueEntityId): Promise<SharedVaultItem[]> {
+    const persistence = await this.ormRepository
+      .createQueryBuilder('shared_vault_item')
+      .where('shared_vault_item.shared_vault_uuid = :sharedVaultUuid', {
+        sharedVaultUuid: sharedVaultId.toString(),
+      })
+      .getMany()
+
+    return persistence.map((p) => this.mapper.toDomain(p))
+  }
+
+  async save(sharedVaultItem: SharedVaultItem): Promise<void> {
+    const persistence = this.mapper.toProjection(sharedVaultItem)
+
+    await this.ormRepository.save(persistence)
+  }
+}

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

@@ -4,15 +4,17 @@ import { MapperInterface, Uuid } from '@standardnotes/domain-core'
 import { SharedVaultRepositoryInterface } from '../../Domain/SharedVault/SharedVaultRepositoryInterface'
 import { TypeORMSharedVault } from './TypeORMSharedVault'
 import { SharedVault } from '../../Domain/SharedVault/SharedVault'
+import { SharedVaultItemRepositoryInterface } from '../../Domain/SharedVault/Item/SharedVaultItemRepositoryInterface'
 
 export class TypeORMSharedVaultRepository implements SharedVaultRepositoryInterface {
   constructor(
     private ormRepository: Repository<TypeORMSharedVault>,
+    private sharedVaultItemRepository: SharedVaultItemRepositoryInterface,
     private mapper: MapperInterface<SharedVault, TypeORMSharedVault>,
   ) {}
 
   async findByUuids(uuids: Uuid[], lastSyncTime?: number | undefined): Promise<SharedVault[]> {
-    const queryBuilder = await this.ormRepository
+    const queryBuilder = this.ormRepository
       .createQueryBuilder('shared_vault')
       .where('shared_vault.uuid IN (:...sharedVaultUuids)', { sharedVaultUuids: uuids.map((uuid) => uuid.value) })
 
@@ -26,6 +28,10 @@ export class TypeORMSharedVaultRepository implements SharedVaultRepositoryInterf
   }
 
   async save(sharedVault: SharedVault): Promise<void> {
+    for (const item of sharedVault.props.sharedVaultItems) {
+      await this.sharedVaultItemRepository.save(item)
+    }
+
     const persistence = this.mapper.toProjection(sharedVault)
 
     await this.ormRepository.save(persistence)

+ 10 - 2
packages/syncing-server/src/Mapping/Http/ItemConflictHttpMapper.ts

@@ -4,9 +4,14 @@ import { Item } from '../../Domain/Item/Item'
 import { ItemConflictHttpRepresentation } from './ItemConflictHttpRepresentation'
 import { ItemConflict } from '../../Domain/Item/ItemConflict'
 import { ItemHttpRepresentation } from './ItemHttpRepresentation'
+import { ItemHash } from '../../Domain/Item/ItemHash'
+import { ItemHashHttpRepresentation } from './ItemHashHttpRepresentation'
 
 export class ItemConflictHttpMapper implements MapperInterface<ItemConflict, ItemConflictHttpRepresentation> {
-  constructor(private mapper: MapperInterface<Item, ItemHttpRepresentation>) {}
+  constructor(
+    private mapper: MapperInterface<Item, ItemHttpRepresentation>,
+    private itemHashMapper: MapperInterface<ItemHash, ItemHashHttpRepresentation>,
+  ) {}
 
   toDomain(_projection: ItemConflictHttpRepresentation): ItemConflict {
     throw new Error('Mapping from http representation to domain is not implemented.')
@@ -14,10 +19,13 @@ export class ItemConflictHttpMapper implements MapperInterface<ItemConflict, Ite
 
   toProjection(domain: ItemConflict): ItemConflictHttpRepresentation {
     const representation: ItemConflictHttpRepresentation = {
-      unsaved_item: domain.unsavedItem,
       type: domain.type,
     }
 
+    if (domain.unsavedItem) {
+      representation.unsaved_item = this.itemHashMapper.toProjection(domain.unsavedItem)
+    }
+
     if (domain.serverItem) {
       representation.server_item = this.mapper.toProjection(domain.serverItem)
     }

+ 2 - 2
packages/syncing-server/src/Mapping/Http/ItemConflictHttpRepresentation.ts

@@ -1,10 +1,10 @@
 import { ConflictType } from '@standardnotes/responses'
 
-import { ItemHash } from '../../Domain/Item/ItemHash'
 import { ItemHttpRepresentation } from './ItemHttpRepresentation'
+import { ItemHashHttpRepresentation } from './ItemHashHttpRepresentation'
 
 export interface ItemConflictHttpRepresentation {
   server_item?: ItemHttpRepresentation
-  unsaved_item?: ItemHash
+  unsaved_item?: ItemHashHttpRepresentation
   type: ConflictType
 }

+ 16 - 0
packages/syncing-server/src/Mapping/Http/ItemHashHttpMapper.ts

@@ -0,0 +1,16 @@
+import { MapperInterface } from '@standardnotes/domain-core'
+
+import { ItemHashHttpRepresentation } from './ItemHashHttpRepresentation'
+import { ItemHash } from '../../Domain/Item/ItemHash'
+
+export class ItemHashHttpMapper implements MapperInterface<ItemHash, ItemHashHttpRepresentation> {
+  toDomain(_projection: ItemHashHttpRepresentation): ItemHash {
+    throw new Error('Mapping from http representation to domain is not implemented.')
+  }
+
+  toProjection(domain: ItemHash): ItemHashHttpRepresentation {
+    return {
+      ...domain.props,
+    }
+  }
+}

+ 17 - 0
packages/syncing-server/src/Mapping/Http/ItemHashHttpRepresentation.ts

@@ -0,0 +1,17 @@
+export interface ItemHashHttpRepresentation {
+  uuid: string
+  user_uuid: string
+  content?: string
+  content_type: string | null
+  deleted?: boolean
+  duplicate_of?: string | null
+  auth_hash?: string
+  enc_item_key?: string
+  items_key_id?: string
+  key_system_identifier: string | null
+  shared_vault_uuid: string | null
+  created_at?: string
+  created_at_timestamp?: number
+  updated_at?: string
+  updated_at_timestamp?: number
+}

+ 1 - 0
packages/syncing-server/src/Mapping/Persistence/SharedVaultPersistenceMapper.ts

@@ -23,6 +23,7 @@ export class SharedVaultPersistenceMapper implements MapperInterface<SharedVault
         fileUploadBytesUsed: projection.fileUploadBytesUsed,
         fileUploadBytesLimit: projection.fileUploadBytesLimit,
         timestamps,
+        sharedVaultItems: [],
       },
       new UniqueEntityId(projection.uuid),
     )