From cb74b23e45b207136e299ce8a3db2c04dc87e21e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karol=20S=C3=B3jko?= Date: Mon, 17 Jul 2023 13:28:50 +0200 Subject: [PATCH] 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 --- .../src/Domain/Statistics/StatisticMeasure.ts | 4 - .../src/Domain/Subscription/Subscription.ts | 4 - packages/analytics/src/Domain/User/User.ts | 4 - .../src/Domain/Authenticator/Authenticator.ts | 4 - .../Authenticator/AuthenticatorChallenge.ts | 4 - .../EmergencyAccessInvitation.ts | 4 - .../auth/src/Domain/Session/SessionTrace.ts | 4 - .../src/Domain/Cache/CacheEntry.ts | 4 - .../domain-core/src/Domain/Core/Aggregate.ts | 7 +- .../domain-core/src/Domain/Core/Entity.ts | 4 + .../revisions/src/Domain/Revision/Revision.ts | 4 - .../src/Domain/Revision/RevisionMetadata.ts | 4 - .../syncing-server/src/Bootstrap/Container.ts | 98 ++- .../syncing-server/src/Bootstrap/Types.ts | 4 +- .../src/Domain/Item/GetItemsResult.ts | 6 - .../syncing-server/src/Domain/Item/Item.ts | 4 - .../src/Domain/Item/ItemHash.ts | 25 +- .../src/Domain/Item/ItemHashProps.ts | 17 + .../src/Domain/Item/ItemService.spec.ts | 785 ------------------ .../src/Domain/Item/ItemService.ts | 239 ------ .../src/Domain/Item/ItemServiceInterface.ts | 11 - .../src/Domain/Item/SaveItemsResult.ts | 8 - .../Item/SaveRule/ContentFilter.spec.ts | 28 +- .../src/Domain/Item/SaveRule/ContentFilter.ts | 4 +- .../Item/SaveRule/ContentTypeFilter.spec.ts | 31 +- .../Domain/Item/SaveRule/ContentTypeFilter.ts | 2 +- .../Item/SaveRule/OwnershipFilter.spec.ts | 69 +- .../SaveRule/TimeDifferenceFilter.spec.ts | 80 +- .../Item/SaveRule/TimeDifferenceFilter.ts | 8 +- .../src/Domain/Message/Message.ts | 4 - .../src/Domain/Notifications/Notification.ts | 4 - .../SharedVault/Item/SharedVaultItem.spec.ts | 18 + .../SharedVault/Item/SharedVaultItem.ts | 13 + .../SharedVault/Item/SharedVaultItemProps.ts | 9 + .../SharedVaultItemRepositoryInterface.ts | 8 + .../Domain/SharedVault/SharedVault.spec.ts | 3 +- .../src/Domain/SharedVault/SharedVault.ts | 8 +- .../Domain/SharedVault/SharedVaultProps.ts | 3 + .../User/Invite/SharedVaultInvite.ts | 4 - .../SharedVault/User/SharedVaultUser.ts | 4 - .../CreateSharedVault/CreateSharedVault.ts | 1 + .../CreateSharedVaultFileValetToken.spec.ts | 1 + .../DeleteSharedVault.spec.ts | 2 + .../GetSharedVaultUsers.spec.ts | 2 + .../GetSharedVaults/GetSharedVaults.spec.ts | 1 + .../InviteUserToSharedVault.spec.ts | 2 + .../RemoveUserFromSharedVault.spec.ts | 2 + .../UseCase/Syncing/GetItems/GetItems.spec.ts | 152 ++++ .../UseCase/Syncing/GetItems/GetItems.ts | 95 +++ .../Syncing/GetItems}/GetItemsDTO.ts | 2 +- .../Syncing/GetItems/GetItemsResult.ts | 6 + .../Syncing/SaveItems/SaveItems.spec.ts | 275 ++++++ .../UseCase/Syncing/SaveItems/SaveItems.ts | 145 ++++ .../Syncing/SaveItems}/SaveItemsDTO.ts | 4 +- .../Syncing/SaveItems/SaveItemsResult.ts | 8 + .../Syncing/SaveNewItem/SaveNewItem.spec.ts | 45 +- .../Syncing/SaveNewItem/SaveNewItem.ts | 28 +- .../Syncing/SyncItems/SyncItems.spec.ts | 134 ++- .../UseCase/Syncing/SyncItems/SyncItems.ts | 45 +- .../UpdateExistingItem.spec.ts | 55 +- .../UpdateExistingItem/UpdateExistingItem.ts | 40 +- .../HomeServer/HomeServerItemsController.ts | 18 +- .../InversifyExpressItemsController.spec.ts | 238 ------ .../Infra/TypeORM/TypeORMSharedVaultItem.ts | 45 + .../TypeORMSharedVaultItemRepository.ts | 30 + .../TypeORM/TypeORMSharedVaultRepository.ts | 8 +- .../Mapping/Http/ItemConflictHttpMapper.ts | 12 +- .../Http/ItemConflictHttpRepresentation.ts | 4 +- .../src/Mapping/Http/ItemHashHttpMapper.ts | 16 + .../Http/ItemHashHttpRepresentation.ts | 17 + .../SharedVaultPersistenceMapper.ts | 1 + 71 files changed, 1366 insertions(+), 1616 deletions(-) delete mode 100644 packages/syncing-server/src/Domain/Item/GetItemsResult.ts create mode 100644 packages/syncing-server/src/Domain/Item/ItemHashProps.ts delete mode 100644 packages/syncing-server/src/Domain/Item/ItemService.spec.ts delete mode 100644 packages/syncing-server/src/Domain/Item/ItemService.ts delete mode 100644 packages/syncing-server/src/Domain/Item/ItemServiceInterface.ts delete mode 100644 packages/syncing-server/src/Domain/Item/SaveItemsResult.ts create mode 100644 packages/syncing-server/src/Domain/SharedVault/Item/SharedVaultItem.spec.ts create mode 100644 packages/syncing-server/src/Domain/SharedVault/Item/SharedVaultItem.ts create mode 100644 packages/syncing-server/src/Domain/SharedVault/Item/SharedVaultItemProps.ts create mode 100644 packages/syncing-server/src/Domain/SharedVault/Item/SharedVaultItemRepositoryInterface.ts create mode 100644 packages/syncing-server/src/Domain/UseCase/Syncing/GetItems/GetItems.spec.ts create mode 100644 packages/syncing-server/src/Domain/UseCase/Syncing/GetItems/GetItems.ts rename packages/syncing-server/src/Domain/{Item => UseCase/Syncing/GetItems}/GetItemsDTO.ts (79%) create mode 100644 packages/syncing-server/src/Domain/UseCase/Syncing/GetItems/GetItemsResult.ts create mode 100644 packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItems.spec.ts create mode 100644 packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItems.ts rename packages/syncing-server/src/Domain/{Item => UseCase/Syncing/SaveItems}/SaveItemsDTO.ts (60%) create mode 100644 packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItemsResult.ts delete mode 100644 packages/syncing-server/src/Infra/InversifyExpressUtils/InversifyExpressItemsController.spec.ts create mode 100644 packages/syncing-server/src/Infra/TypeORM/TypeORMSharedVaultItem.ts create mode 100644 packages/syncing-server/src/Infra/TypeORM/TypeORMSharedVaultItemRepository.ts create mode 100644 packages/syncing-server/src/Mapping/Http/ItemHashHttpMapper.ts create mode 100644 packages/syncing-server/src/Mapping/Http/ItemHashHttpRepresentation.ts diff --git a/packages/analytics/src/Domain/Statistics/StatisticMeasure.ts b/packages/analytics/src/Domain/Statistics/StatisticMeasure.ts index b7576ca31..abf17ee5e 100644 --- a/packages/analytics/src/Domain/Statistics/StatisticMeasure.ts +++ b/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 { - get id(): UniqueEntityId { - return this._id - } - get name(): string { return this.props.name.value } diff --git a/packages/analytics/src/Domain/Subscription/Subscription.ts b/packages/analytics/src/Domain/Subscription/Subscription.ts index 073dc5ed5..465c08550 100644 --- a/packages/analytics/src/Domain/Subscription/Subscription.ts +++ b/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 { - get id(): UniqueEntityId { - return this._id - } - private constructor(props: SubscriptionProps, id?: UniqueEntityId) { super(props, id) } diff --git a/packages/analytics/src/Domain/User/User.ts b/packages/analytics/src/Domain/User/User.ts index 3ee0ffafd..d42a4e926 100644 --- a/packages/analytics/src/Domain/User/User.ts +++ b/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 { - get id(): UniqueEntityId { - return this._id - } - private constructor(props: UserProps, id?: UniqueEntityId) { super(props, id) } diff --git a/packages/auth/src/Domain/Authenticator/Authenticator.ts b/packages/auth/src/Domain/Authenticator/Authenticator.ts index aae19725b..32bc3bb77 100644 --- a/packages/auth/src/Domain/Authenticator/Authenticator.ts +++ b/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 { - get id(): UniqueEntityId { - return this._id - } - private constructor(props: AuthenticatorProps, id?: UniqueEntityId) { super(props, id) } diff --git a/packages/auth/src/Domain/Authenticator/AuthenticatorChallenge.ts b/packages/auth/src/Domain/Authenticator/AuthenticatorChallenge.ts index b97303c58..3630b6aae 100644 --- a/packages/auth/src/Domain/Authenticator/AuthenticatorChallenge.ts +++ b/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 { - get id(): UniqueEntityId { - return this._id - } - private constructor(props: AuthenticatorChallengeProps, id?: UniqueEntityId) { super(props, id) } diff --git a/packages/auth/src/Domain/EmergencyAccess/EmergencyAccessInvitation.ts b/packages/auth/src/Domain/EmergencyAccess/EmergencyAccessInvitation.ts index 0efd02746..29faef65c 100644 --- a/packages/auth/src/Domain/EmergencyAccess/EmergencyAccessInvitation.ts +++ b/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 { - get id(): UniqueEntityId { - return this._id - } - private constructor(props: EmergencyAccessInvitationProps, id?: UniqueEntityId) { super(props, id) } diff --git a/packages/auth/src/Domain/Session/SessionTrace.ts b/packages/auth/src/Domain/Session/SessionTrace.ts index 550607cc6..f47414961 100644 --- a/packages/auth/src/Domain/Session/SessionTrace.ts +++ b/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 { - get id(): UniqueEntityId { - return this._id - } - private constructor(props: SessionTraceProps, id?: UniqueEntityId) { super(props, id) } diff --git a/packages/domain-core/src/Domain/Cache/CacheEntry.ts b/packages/domain-core/src/Domain/Cache/CacheEntry.ts index 31f099dfa..423128d94 100644 --- a/packages/domain-core/src/Domain/Cache/CacheEntry.ts +++ b/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 { - get id(): UniqueEntityId { - return this._id - } - private constructor(props: CacheEntryProps, id?: UniqueEntityId) { super(props, id) } diff --git a/packages/domain-core/src/Domain/Core/Aggregate.ts b/packages/domain-core/src/Domain/Core/Aggregate.ts index 406670084..3e6ad3a39 100644 --- a/packages/domain-core/src/Domain/Core/Aggregate.ts +++ b/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 extends Entity { - get id(): UniqueEntityId { - return this._id - } -} +export abstract class Aggregate extends Entity {} diff --git a/packages/domain-core/src/Domain/Core/Entity.ts b/packages/domain-core/src/Domain/Core/Entity.ts index de36994d2..fde0bfd78 100644 --- a/packages/domain-core/src/Domain/Core/Entity.ts +++ b/packages/domain-core/src/Domain/Core/Entity.ts @@ -9,6 +9,10 @@ export abstract class Entity { this._id = id ? id : new UniqueEntityId() } + get id(): UniqueEntityId { + return this._id + } + public equals(object?: Entity): boolean { if (object == null || object == undefined) { return false diff --git a/packages/revisions/src/Domain/Revision/Revision.ts b/packages/revisions/src/Domain/Revision/Revision.ts index 29d2e0f9f..18d865092 100644 --- a/packages/revisions/src/Domain/Revision/Revision.ts +++ b/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 { - get id(): UniqueEntityId { - return this._id - } - private constructor(props: RevisionProps, id?: UniqueEntityId) { super(props, id) } diff --git a/packages/revisions/src/Domain/Revision/RevisionMetadata.ts b/packages/revisions/src/Domain/Revision/RevisionMetadata.ts index 82183e603..d3f56788b 100644 --- a/packages/revisions/src/Domain/Revision/RevisionMetadata.ts +++ b/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 { - get id(): UniqueEntityId { - return this._id - } - private constructor(props: RevisionMetadataProps, id?: UniqueEntityId) { super(props, id) } diff --git a/packages/syncing-server/src/Bootstrap/Container.ts b/packages/syncing-server/src/Bootstrap/Container.ts index 37fe149a5..84bd34708 100644 --- a/packages/syncing-server/src/Bootstrap/Container.ts +++ b/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>(TYPES.Sync_ItemPersistenceMapper) .toConstantValue(new ItemPersistenceMapper()) + container + .bind>(TYPES.Sync_ItemHashHttpMapper) + .toConstantValue(new ItemHashHttpMapper()) container .bind>(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>(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>(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(TYPES.Sync_OwnershipFilter).toConstantValue(new OwnershipFilter()) + container + .bind(TYPES.Sync_TimeDifferenceFilter) + .toConstantValue(new TimeDifferenceFilter(container.get(TYPES.Sync_Timer))) + container.bind(TYPES.Sync_ContentTypeFilter).toConstantValue(new ContentTypeFilter()) + container.bind(TYPES.Sync_ContentFilter).toConstantValue(new ContentFilter()) + container + .bind(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(TYPES.Sync_SyncItems).toDynamicValue((context: interfaces.Context) => { - return new SyncItems(context.container.get(TYPES.Sync_ItemService)) - }) - container.bind(TYPES.Sync_CheckIntegrity).toDynamicValue((context: interfaces.Context) => { - return new CheckIntegrity(context.container.get(TYPES.Sync_ItemRepository)) - }) - container.bind(TYPES.Sync_GetItem).toDynamicValue((context: interfaces.Context) => { - return new GetItem(context.container.get(TYPES.Sync_ItemRepository)) - }) + container + .bind(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(TYPES.Sync_SaveNewItem) .toConstantValue( @@ -313,41 +343,35 @@ export class ContainerConfigLoader { container.get(TYPES.Sync_REVISIONS_FREQUENCY), ), ) - - // Services - container.bind(TYPES.Sync_OwnershipFilter).toConstantValue(new OwnershipFilter()) container - .bind(TYPES.Sync_TimeDifferenceFilter) - .toConstantValue(new TimeDifferenceFilter(container.get(TYPES.Sync_Timer))) - container.bind(TYPES.Sync_ContentTypeFilter).toConstantValue(new ContentTypeFilter()) - container.bind(TYPES.Sync_ContentFilter).toConstantValue(new ContentFilter()) - - container - .bind(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(TYPES.Sync_ItemService) + .bind(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(TYPES.Sync_SyncItems) + .toConstantValue( + new SyncItems( + container.get(TYPES.Sync_ItemRepository), + container.get(TYPES.Sync_GetItems), + container.get(TYPES.Sync_SaveItems), + ), + ) + container.bind(TYPES.Sync_CheckIntegrity).toDynamicValue((context: interfaces.Context) => { + return new CheckIntegrity(context.container.get(TYPES.Sync_ItemRepository)) + }) + container.bind(TYPES.Sync_GetItem).toDynamicValue((context: interfaces.Context) => { + return new GetItem(context.container.get(TYPES.Sync_ItemRepository)) + }) + + // Services container .bind(TYPES.Sync_SyncResponseFactory20161215) .toConstantValue(new SyncResponseFactory20161215(container.get(TYPES.Sync_ItemHttpMapper))) diff --git a/packages/syncing-server/src/Bootstrap/Types.ts b/packages/syncing-server/src/Bootstrap/Types.ts index f10777821..603917b0c 100644 --- a/packages/syncing-server/src/Bootstrap/Types.ts +++ b/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'), diff --git a/packages/syncing-server/src/Domain/Item/GetItemsResult.ts b/packages/syncing-server/src/Domain/Item/GetItemsResult.ts deleted file mode 100644 index 46f3b19a7..000000000 --- a/packages/syncing-server/src/Domain/Item/GetItemsResult.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Item } from './Item' - -export type GetItemsResult = { - items: Array - cursorToken?: string -} diff --git a/packages/syncing-server/src/Domain/Item/Item.ts b/packages/syncing-server/src/Domain/Item/Item.ts index 2e12198ad..26df940b3 100644 --- a/packages/syncing-server/src/Domain/Item/Item.ts +++ b/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 { - get id(): UniqueEntityId { - return this._id - } - private constructor(props: ItemProps, id?: UniqueEntityId) { super(props, id) } diff --git a/packages/syncing-server/src/Domain/Item/ItemHash.ts b/packages/syncing-server/src/Domain/Item/ItemHash.ts index 42b1e780e..d8d886d0c 100644 --- a/packages/syncing-server/src/Domain/Item/ItemHash.ts +++ b/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 { + private constructor(props: ItemHashProps) { + super(props) + } + + static create(props: ItemHashProps): Result { + return Result.ok(new ItemHash(props)) + } } diff --git a/packages/syncing-server/src/Domain/Item/ItemHashProps.ts b/packages/syncing-server/src/Domain/Item/ItemHashProps.ts new file mode 100644 index 000000000..5464c0d1b --- /dev/null +++ b/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 +} diff --git a/packages/syncing-server/src/Domain/Item/ItemService.spec.ts b/packages/syncing-server/src/Domain/Item/ItemService.spec.ts deleted file mode 100644 index db1441e21..000000000 --- a/packages/syncing-server/src/Domain/Item/ItemService.spec.ts +++ /dev/null @@ -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 - - 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 - - itemTransferCalculator = {} as jest.Mocked - itemTransferCalculator.computeItemUuidsToFetch = jest - .fn() - .mockReturnValue([item1.id.toString(), item2.id.toString()]) - - itemRepository = {} as jest.Mocked - itemRepository.findAll = jest.fn().mockReturnValue([item1, item2]) - itemRepository.countAll = jest.fn().mockReturnValue(2) - - timer = {} as jest.Mocked - 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.error = jest.fn() - logger.warn = jest.fn() - - syncToken = Buffer.from('2:1616164633.241564', 'utf-8').toString('base64') - - itemSaveValidator = {} as jest.Mocked - 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 - saveNewItemUseCase.execute = jest.fn().mockReturnValue(Result.ok(newItem)) - - updateExistingItemUseCase = {} as jest.Mocked - 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(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 - 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 - - 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==', - }) - }) -}) diff --git a/packages/syncing-server/src/Domain/Item/ItemService.ts b/packages/syncing-server/src/Domain/Item/ItemService.ts deleted file mode 100644 index 2a02a8bae..000000000 --- a/packages/syncing-server/src/Domain/Item/ItemService.ts +++ /dev/null @@ -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 { - 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 = [] - 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 { - const savedItems: Array = [] - const conflicts: Array = [] - - 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): Promise> { - const itemsKeys = await this.itemRepository.findAll({ - userUuid, - contentType: ContentType.TYPES.ItemsKey, - sortBy: 'updated_at_timestamp', - sortOrder: 'ASC', - }) - - const retrievedItemsIds: Array = 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): 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') - } - } -} diff --git a/packages/syncing-server/src/Domain/Item/ItemServiceInterface.ts b/packages/syncing-server/src/Domain/Item/ItemServiceInterface.ts deleted file mode 100644 index da0d3e6ce..000000000 --- a/packages/syncing-server/src/Domain/Item/ItemServiceInterface.ts +++ /dev/null @@ -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 - saveItems(dto: SaveItemsDTO): Promise - frontLoadKeysItemsToTop(userUuid: string, retrievedItems: Array): Promise> -} diff --git a/packages/syncing-server/src/Domain/Item/SaveItemsResult.ts b/packages/syncing-server/src/Domain/Item/SaveItemsResult.ts deleted file mode 100644 index 9a5146ff5..000000000 --- a/packages/syncing-server/src/Domain/Item/SaveItemsResult.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Item } from './Item' -import { ItemConflict } from './ItemConflict' - -export type SaveItemsResult = { - savedItems: Array - conflicts: Array - syncToken: string -} diff --git a/packages/syncing-server/src/Domain/Item/SaveRule/ContentFilter.spec.ts b/packages/syncing-server/src/Domain/Item/SaveRule/ContentFilter.spec.ts index b969be759..77199ac0c 100644 --- a/packages/syncing-server/src/Domain/Item/SaveRule/ContentFilter.spec.ts +++ b/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, }) diff --git a/packages/syncing-server/src/Domain/Item/SaveRule/ContentFilter.ts b/packages/syncing-server/src/Domain/Item/SaveRule/ContentFilter.ts index 07a8dd841..0d7e42c8c 100644 --- a/packages/syncing-server/src/Domain/Item/SaveRule/ContentFilter.ts +++ b/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 { - 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 { diff --git a/packages/syncing-server/src/Domain/Item/SaveRule/ContentTypeFilter.spec.ts b/packages/syncing-server/src/Domain/Item/SaveRule/ContentTypeFilter.spec.ts index b43e56a63..2ad174299 100644 --- a/packages/syncing-server/src/Domain/Item/SaveRule/ContentTypeFilter.spec.ts +++ b/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, }) diff --git a/packages/syncing-server/src/Domain/Item/SaveRule/ContentTypeFilter.ts b/packages/syncing-server/src/Domain/Item/SaveRule/ContentTypeFilter.ts index 843fa034c..1c07ffb95 100644 --- a/packages/syncing-server/src/Domain/Item/SaveRule/ContentTypeFilter.ts +++ b/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 { - const contentTypeOrError = ContentType.create(dto.itemHash.content_type) + const contentTypeOrError = ContentType.create(dto.itemHash.props.content_type) if (contentTypeOrError.isFailed()) { return { passed: false, diff --git a/packages/syncing-server/src/Domain/Item/SaveRule/OwnershipFilter.spec.ts b/packages/syncing-server/src/Domain/Item/SaveRule/OwnershipFilter.spec.ts index 2f2eaa8fe..73f821e7b 100644 --- a/packages/syncing-server/src/Domain/Item/SaveRule/OwnershipFilter.spec.ts +++ b/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', }, }) diff --git a/packages/syncing-server/src/Domain/Item/SaveRule/TimeDifferenceFilter.spec.ts b/packages/syncing-server/src/Domain/Item/SaveRule/TimeDifferenceFilter.spec.ts index 61a6fd67e..2edf72898 100644 --- a/packages/syncing-server/src/Domain/Item/SaveRule/TimeDifferenceFilter.spec.ts +++ b/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 + }).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', diff --git a/packages/syncing-server/src/Domain/Item/SaveRule/TimeDifferenceFilter.ts b/packages/syncing-server/src/Domain/Item/SaveRule/TimeDifferenceFilter.ts index 7bcb7efde..ce9fef98b 100644 --- a/packages/syncing-server/src/Domain/Item/SaveRule/TimeDifferenceFilter.ts +++ b/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 { diff --git a/packages/syncing-server/src/Domain/Message/Message.ts b/packages/syncing-server/src/Domain/Message/Message.ts index 9800c4d8c..b4484216b 100644 --- a/packages/syncing-server/src/Domain/Message/Message.ts +++ b/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 { - get id(): UniqueEntityId { - return this._id - } - private constructor(props: MessageProps, id?: UniqueEntityId) { super(props, id) } diff --git a/packages/syncing-server/src/Domain/Notifications/Notification.ts b/packages/syncing-server/src/Domain/Notifications/Notification.ts index 30ddd85ee..357fe2948 100644 --- a/packages/syncing-server/src/Domain/Notifications/Notification.ts +++ b/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 { - get id(): UniqueEntityId { - return this._id - } - private constructor(props: NotificationProps, id?: UniqueEntityId) { super(props, id) } diff --git a/packages/syncing-server/src/Domain/SharedVault/Item/SharedVaultItem.spec.ts b/packages/syncing-server/src/Domain/SharedVault/Item/SharedVaultItem.spec.ts new file mode 100644 index 000000000..60315022f --- /dev/null +++ b/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() + }) +}) diff --git a/packages/syncing-server/src/Domain/SharedVault/Item/SharedVaultItem.ts b/packages/syncing-server/src/Domain/SharedVault/Item/SharedVaultItem.ts new file mode 100644 index 000000000..95b03e851 --- /dev/null +++ b/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 { + private constructor(props: SharedVaultItemProps, id?: UniqueEntityId) { + super(props, id) + } + + static create(props: SharedVaultItemProps, id?: UniqueEntityId): Result { + return Result.ok(new SharedVaultItem(props, id)) + } +} diff --git a/packages/syncing-server/src/Domain/SharedVault/Item/SharedVaultItemProps.ts b/packages/syncing-server/src/Domain/SharedVault/Item/SharedVaultItemProps.ts new file mode 100644 index 000000000..566dd2849 --- /dev/null +++ b/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 +} diff --git a/packages/syncing-server/src/Domain/SharedVault/Item/SharedVaultItemRepositoryInterface.ts b/packages/syncing-server/src/Domain/SharedVault/Item/SharedVaultItemRepositoryInterface.ts new file mode 100644 index 000000000..4b83e5f42 --- /dev/null +++ b/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 + findBySharedVaultId(sharedVaultId: UniqueEntityId): Promise +} diff --git a/packages/syncing-server/src/Domain/SharedVault/SharedVault.spec.ts b/packages/syncing-server/src/Domain/SharedVault/SharedVault.spec.ts index 11bb99707..91a38a481 100644 --- a/packages/syncing-server/src/Domain/SharedVault/SharedVault.spec.ts +++ b/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() diff --git a/packages/syncing-server/src/Domain/SharedVault/SharedVault.ts b/packages/syncing-server/src/Domain/SharedVault/SharedVault.ts index b3016a36a..e26f188f1 100644 --- a/packages/syncing-server/src/Domain/SharedVault/SharedVault.ts +++ b/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 { - get id(): UniqueEntityId { - return this._id - } - +export class SharedVault extends Aggregate { private constructor(props: SharedVaultProps, id?: UniqueEntityId) { super(props, id) } diff --git a/packages/syncing-server/src/Domain/SharedVault/SharedVaultProps.ts b/packages/syncing-server/src/Domain/SharedVault/SharedVaultProps.ts index 678f1146c..7d9882b68 100644 --- a/packages/syncing-server/src/Domain/SharedVault/SharedVaultProps.ts +++ b/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[] } diff --git a/packages/syncing-server/src/Domain/SharedVault/User/Invite/SharedVaultInvite.ts b/packages/syncing-server/src/Domain/SharedVault/User/Invite/SharedVaultInvite.ts index 14b9a2bf1..fe23c9486 100644 --- a/packages/syncing-server/src/Domain/SharedVault/User/Invite/SharedVaultInvite.ts +++ b/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 { - get id(): UniqueEntityId { - return this._id - } - private constructor(props: SharedVaultInviteProps, id?: UniqueEntityId) { super(props, id) } diff --git a/packages/syncing-server/src/Domain/SharedVault/User/SharedVaultUser.ts b/packages/syncing-server/src/Domain/SharedVault/User/SharedVaultUser.ts index 5f05c8b50..9295f3bcc 100644 --- a/packages/syncing-server/src/Domain/SharedVault/User/SharedVaultUser.ts +++ b/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 { - get id(): UniqueEntityId { - return this._id - } - private constructor(props: SharedVaultUserProps, id?: UniqueEntityId) { super(props, id) } diff --git a/packages/syncing-server/src/Domain/UseCase/SharedVaults/CreateSharedVault/CreateSharedVault.ts b/packages/syncing-server/src/Domain/UseCase/SharedVaults/CreateSharedVault/CreateSharedVault.ts index ec09ea17a..3b9eae5d2 100644 --- a/packages/syncing-server/src/Domain/UseCase/SharedVaults/CreateSharedVault/CreateSharedVault.ts +++ b/packages/syncing-server/src/Domain/UseCase/SharedVaults/CreateSharedVault/CreateSharedVault.ts @@ -32,6 +32,7 @@ export class CreateSharedVault implements UseCaseInterface { fileUploadBytesUsed: 2, userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), timestamps: Timestamps.create(123, 123).getValue(), + sharedVaultItems: [], }).getValue() sharedVaultRepository = {} as jest.Mocked diff --git a/packages/syncing-server/src/Domain/UseCase/SharedVaults/DeleteSharedVault/DeleteSharedVault.spec.ts b/packages/syncing-server/src/Domain/UseCase/SharedVaults/DeleteSharedVault/DeleteSharedVault.spec.ts index bef949e37..470cba766 100644 --- a/packages/syncing-server/src/Domain/UseCase/SharedVaults/DeleteSharedVault/DeleteSharedVault.spec.ts +++ b/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 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() diff --git a/packages/syncing-server/src/Domain/UseCase/SharedVaults/GetSharedVaultUsers/GetSharedVaultUsers.spec.ts b/packages/syncing-server/src/Domain/UseCase/SharedVaults/GetSharedVaultUsers/GetSharedVaultUsers.spec.ts index 896cc3f89..02b9389db 100644 --- a/packages/syncing-server/src/Domain/UseCase/SharedVaults/GetSharedVaultUsers/GetSharedVaultUsers.spec.ts +++ b/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) diff --git a/packages/syncing-server/src/Domain/UseCase/SharedVaults/GetSharedVaults/GetSharedVaults.spec.ts b/packages/syncing-server/src/Domain/UseCase/SharedVaults/GetSharedVaults/GetSharedVaults.spec.ts index 053d590ed..9c85ef460 100644 --- a/packages/syncing-server/src/Domain/UseCase/SharedVaults/GetSharedVaults/GetSharedVaults.spec.ts +++ b/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 sharedVaultRepository.findByUuids = jest.fn().mockResolvedValue([sharedVault]) diff --git a/packages/syncing-server/src/Domain/UseCase/SharedVaults/InviteUserToSharedVault/InviteUserToSharedVault.spec.ts b/packages/syncing-server/src/Domain/UseCase/SharedVaults/InviteUserToSharedVault/InviteUserToSharedVault.spec.ts index 610b3c76f..6d95c5b33 100644 --- a/packages/syncing-server/src/Domain/UseCase/SharedVaults/InviteUserToSharedVault/InviteUserToSharedVault.spec.ts +++ b/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 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) diff --git a/packages/syncing-server/src/Domain/UseCase/SharedVaults/RemoveUserFromSharedVault/RemoveUserFromSharedVault.spec.ts b/packages/syncing-server/src/Domain/UseCase/SharedVaults/RemoveUserFromSharedVault/RemoveUserFromSharedVault.spec.ts index 03c4d7e89..7a5e99577 100644 --- a/packages/syncing-server/src/Domain/UseCase/SharedVaults/RemoveUserFromSharedVault/RemoveUserFromSharedVault.spec.ts +++ b/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 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) diff --git a/packages/syncing-server/src/Domain/UseCase/Syncing/GetItems/GetItems.spec.ts b/packages/syncing-server/src/Domain/UseCase/Syncing/GetItems/GetItems.spec.ts new file mode 100644 index 000000000..fd5b16ce5 --- /dev/null +++ b/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 + itemRepository.findAll = jest.fn().mockResolvedValue([item]) + itemRepository.countAll = jest.fn().mockResolvedValue(1) + + itemTransferCalculator = {} as jest.Mocked + itemTransferCalculator.computeItemUuidsToFetch = jest.fn().mockResolvedValue(['item-uuid']) + + timer = {} as jest.Mocked + 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, + }) + }) +}) diff --git a/packages/syncing-server/src/Domain/UseCase/Syncing/GetItems/GetItems.ts b/packages/syncing-server/src/Domain/UseCase/Syncing/GetItems/GetItems.ts new file mode 100644 index 000000000..35a5cac74 --- /dev/null +++ b/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 { + 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> { + 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 = [] + 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 { + 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') + } + } +} diff --git a/packages/syncing-server/src/Domain/Item/GetItemsDTO.ts b/packages/syncing-server/src/Domain/UseCase/Syncing/GetItems/GetItemsDTO.ts similarity index 79% rename from packages/syncing-server/src/Domain/Item/GetItemsDTO.ts rename to packages/syncing-server/src/Domain/UseCase/Syncing/GetItems/GetItemsDTO.ts index ac53950f9..c20f2510b 100644 --- a/packages/syncing-server/src/Domain/Item/GetItemsDTO.ts +++ b/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 diff --git a/packages/syncing-server/src/Domain/UseCase/Syncing/GetItems/GetItemsResult.ts b/packages/syncing-server/src/Domain/UseCase/Syncing/GetItems/GetItemsResult.ts new file mode 100644 index 000000000..9b34f6dae --- /dev/null +++ b/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 +} diff --git a/packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItems.spec.ts b/packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItems.spec.ts new file mode 100644 index 000000000..09fdf2d61 --- /dev/null +++ b/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 + itemSaveValidator.validate = jest.fn().mockResolvedValue({ passed: true }) + + itemRepository = {} as jest.Mocked + itemRepository.findByUuid = jest.fn().mockResolvedValue(null) + + timer = {} as jest.Mocked + 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.execute = jest.fn().mockReturnValue(Result.ok(savedItem)) + + updateExistingItem = {} as jest.Mocked + updateExistingItem.execute = jest.fn().mockResolvedValue(Result.ok(savedItem)) + + logger = {} as jest.Mocked + 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') + }) +}) diff --git a/packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItems.ts b/packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItems.ts new file mode 100644 index 000000000..c07014e5a --- /dev/null +++ b/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 { + 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> { + const savedItems: Array = [] + const conflicts: Array = [] + + 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): 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') + } +} diff --git a/packages/syncing-server/src/Domain/Item/SaveItemsDTO.ts b/packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItemsDTO.ts similarity index 60% rename from packages/syncing-server/src/Domain/Item/SaveItemsDTO.ts rename to packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItemsDTO.ts index 2b771b379..40c83b0ee 100644 --- a/packages/syncing-server/src/Domain/Item/SaveItemsDTO.ts +++ b/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 diff --git a/packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItemsResult.ts b/packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItemsResult.ts new file mode 100644 index 000000000..d1c694f78 --- /dev/null +++ b/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 +} diff --git a/packages/syncing-server/src/Domain/UseCase/Syncing/SaveNewItem/SaveNewItem.spec.ts b/packages/syncing-server/src/Domain/UseCase/Syncing/SaveNewItem/SaveNewItem.spec.ts index 21abecf08..9dc48394d 100644 --- a/packages/syncing-server/src/Domain/UseCase/Syncing/SaveNewItem/SaveNewItem.spec.ts +++ b/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 + }).getValue() itemRepository = {} as jest.Mocked 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', diff --git a/packages/syncing-server/src/Domain/UseCase/Syncing/SaveNewItem/SaveNewItem.ts b/packages/syncing-server/src/Domain/UseCase/Syncing/SaveNewItem/SaveNewItem.ts index 3cd1fef94..c7dff06e5 100644 --- a/packages/syncing-server/src/Domain/UseCase/Syncing/SaveNewItem/SaveNewItem.ts +++ b/packages/syncing-server/src/Domain/UseCase/Syncing/SaveNewItem/SaveNewItem.ts @@ -38,15 +38,15 @@ export class SaveNewItem implements UseCaseInterface { } 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 { 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 { 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()) diff --git a/packages/syncing-server/src/Domain/UseCase/Syncing/SyncItems/SyncItems.spec.ts b/packages/syncing-server/src/Domain/UseCase/Syncing/SyncItems/SyncItems.spec.ts index b9d9cf0fb..0ca87d60a 100644 --- a/packages/syncing-server/src/Domain/UseCase/Syncing/SyncItems/SyncItems.spec.ts +++ b/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 - 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 + getItemsUseCase.execute = jest.fn().mockReturnValue( + Result.ok({ + items: [item1], + cursorToken: 'asdzxc', + }), + ) + + saveItemsUseCase = {} as jest.Mocked + saveItemsUseCase.execute = jest.fn().mockReturnValue( + Result.ok({ + savedItems: [item2], + conflicts: [], + syncToken: 'qwerty', + }), + ) + + itemRepository = {} as jest.Mocked + 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() + }) }) diff --git a/packages/syncing-server/src/Domain/UseCase/Syncing/SyncItems/SyncItems.ts b/packages/syncing-server/src/Domain/UseCase/Syncing/SyncItems/SyncItems.ts index 15f3ae2d8..9090a4ae7 100644 --- a/packages/syncing-server/src/Domain/UseCase/Syncing/SyncItems/SyncItems.ts +++ b/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 { - constructor(private itemService: ItemServiceInterface) {} + constructor( + private itemRepository: ItemRepositoryInterface, + private getItemsUseCase: GetItems, + private saveItemsUseCase: SaveItems, + ) {} async execute(dto: SyncItemsDTO): Promise> { - 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 { return retrievedItems.filter((item: Item) => syncConflictIds.indexOf(item.id.toString()) === -1) } + + private async frontLoadKeysItemsToTop(userUuid: string, retrievedItems: Array): Promise> { + const itemsKeys = await this.itemRepository.findAll({ + userUuid, + contentType: ContentType.TYPES.ItemsKey, + sortBy: 'updated_at_timestamp', + sortOrder: 'ASC', + }) + + const retrievedItemsIds: Array = retrievedItems.map((item: Item) => item.id.toString()) + + itemsKeys.forEach((itemKey: Item) => { + if (retrievedItemsIds.indexOf(itemKey.id.toString()) === -1) { + retrievedItems.unshift(itemKey) + } + }) + + return retrievedItems + } } diff --git a/packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem.spec.ts b/packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem.spec.ts index a5fc81095..3e4a668dd 100644 --- a/packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem.spec.ts +++ b/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 + }).getValue() itemRepository = {} as jest.Mocked 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', }) diff --git a/packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem.ts b/packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem.ts index ad79cd099..316f8cc00 100644 --- a/packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem.ts +++ b/packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem.ts @@ -27,12 +27,12 @@ export class UpdateExistingItem implements UseCaseInterface { } 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 { 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 { 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 { 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 { 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 diff --git a/packages/syncing-server/src/Infra/InversifyExpressUtils/HomeServer/HomeServerItemsController.ts b/packages/syncing-server/src/Infra/InversifyExpressUtils/HomeServer/HomeServerItemsController.ts index 8a5e8a4b0..fefe64e7e 100644 --- a/packages/syncing-server/src/Infra/InversifyExpressUtils/HomeServer/HomeServerItemsController.ts +++ b/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 { - 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({ diff --git a/packages/syncing-server/src/Infra/InversifyExpressUtils/InversifyExpressItemsController.spec.ts b/packages/syncing-server/src/Infra/InversifyExpressUtils/InversifyExpressItemsController.spec.ts deleted file mode 100644 index 4abf360c8..000000000 --- a/packages/syncing-server/src/Infra/InversifyExpressUtils/InversifyExpressItemsController.spec.ts +++ /dev/null @@ -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 - 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> - mapper.toProjection = jest.fn().mockReturnValue({ foo: 'bar' }) - - syncItems = {} as jest.Mocked - syncItems.execute = jest.fn().mockReturnValue(Result.ok({ foo: 'bar' })) - - checkIntegrity = {} as jest.Mocked - checkIntegrity.execute = jest.fn().mockReturnValue(Result.ok([{ uuid: '1-2-3', updated_at_timestamp: 2 }])) - - getItem = {} as jest.Mocked - getItem.execute = jest.fn().mockReturnValue(Result.ok({} as jest.Mocked)) - - request = { - headers: {}, - body: {}, - params: {}, - } as jest.Mocked - - 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 - response.locals.user = { - uuid: '123', - } - response.locals.freeUser = false - - syncResponse = {} as jest.Mocked - - syncResponseFactory = {} as jest.Mocked - syncResponseFactory.createResponse = jest.fn().mockReturnValue(syncResponse) - - syncResponceFactoryResolver = {} as jest.Mocked - syncResponceFactoryResolver.resolveSyncResponseFactoryVersion = jest.fn().mockReturnValue(syncResponseFactory) - }) - - it('should get a single item', async () => { - request.params.uuid = '1-2-3' - const httpResponse = 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 = 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 = 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 = 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 = 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 = 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 = 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) - }) -}) diff --git a/packages/syncing-server/src/Infra/TypeORM/TypeORMSharedVaultItem.ts b/packages/syncing-server/src/Infra/TypeORM/TypeORMSharedVaultItem.ts new file mode 100644 index 000000000..92ab74595 --- /dev/null +++ b/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 +} diff --git a/packages/syncing-server/src/Infra/TypeORM/TypeORMSharedVaultItemRepository.ts b/packages/syncing-server/src/Infra/TypeORM/TypeORMSharedVaultItemRepository.ts new file mode 100644 index 000000000..0348bf474 --- /dev/null +++ b/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, + private mapper: MapperInterface, + ) {} + + async findBySharedVaultId(sharedVaultId: UniqueEntityId): Promise { + 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 { + const persistence = this.mapper.toProjection(sharedVaultItem) + + await this.ormRepository.save(persistence) + } +} diff --git a/packages/syncing-server/src/Infra/TypeORM/TypeORMSharedVaultRepository.ts b/packages/syncing-server/src/Infra/TypeORM/TypeORMSharedVaultRepository.ts index 57fdee238..4fcb77dc5 100644 --- a/packages/syncing-server/src/Infra/TypeORM/TypeORMSharedVaultRepository.ts +++ b/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, + private sharedVaultItemRepository: SharedVaultItemRepositoryInterface, private mapper: MapperInterface, ) {} async findByUuids(uuids: Uuid[], lastSyncTime?: number | undefined): Promise { - 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 { + for (const item of sharedVault.props.sharedVaultItems) { + await this.sharedVaultItemRepository.save(item) + } + const persistence = this.mapper.toProjection(sharedVault) await this.ormRepository.save(persistence) diff --git a/packages/syncing-server/src/Mapping/Http/ItemConflictHttpMapper.ts b/packages/syncing-server/src/Mapping/Http/ItemConflictHttpMapper.ts index 2f9f9a089..3e529bc77 100644 --- a/packages/syncing-server/src/Mapping/Http/ItemConflictHttpMapper.ts +++ b/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 { - constructor(private mapper: MapperInterface) {} + constructor( + private mapper: MapperInterface, + private itemHashMapper: MapperInterface, + ) {} 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 { + toDomain(_projection: ItemHashHttpRepresentation): ItemHash { + throw new Error('Mapping from http representation to domain is not implemented.') + } + + toProjection(domain: ItemHash): ItemHashHttpRepresentation { + return { + ...domain.props, + } + } +} diff --git a/packages/syncing-server/src/Mapping/Http/ItemHashHttpRepresentation.ts b/packages/syncing-server/src/Mapping/Http/ItemHashHttpRepresentation.ts new file mode 100644 index 000000000..9a0840e24 --- /dev/null +++ b/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 +} diff --git a/packages/syncing-server/src/Mapping/Persistence/SharedVaultPersistenceMapper.ts b/packages/syncing-server/src/Mapping/Persistence/SharedVaultPersistenceMapper.ts index eced5d7c4..064e0a104 100644 --- a/packages/syncing-server/src/Mapping/Persistence/SharedVaultPersistenceMapper.ts +++ b/packages/syncing-server/src/Mapping/Persistence/SharedVaultPersistenceMapper.ts @@ -23,6 +23,7 @@ export class SharedVaultPersistenceMapper implements MapperInterface