feat: domain items (#655)

* feat: content type as a value object

* feat: turn items into domain entities

* fix: update @standardnotes/api

* fix(syncing-server): bindings order
This commit is contained in:
Karol Sójko 2023-07-12 10:19:22 +02:00 committed by GitHub
parent c970b1ea68
commit a0af8f0025
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
103 changed files with 2275 additions and 1873 deletions

90
.pnp.cjs generated
View file

@ -4560,17 +4560,16 @@ const RAW_RUNTIME_STATE =
}]\
]],\
["@standardnotes/api", [\
["npm:1.26.10", {\
"packageLocation": "./.yarn/cache/@standardnotes-api-npm-1.26.10-f6165cafd3-3c3561aec8.zip/node_modules/@standardnotes/api/",\
["npm:1.26.25", {\
"packageLocation": "./.yarn/cache/@standardnotes-api-npm-1.26.25-fbb86eb9b7-68a820bd36.zip/node_modules/@standardnotes/api/",\
"packageDependencies": [\
["@standardnotes/api", "npm:1.26.10"],\
["@standardnotes/api", "npm:1.26.25"],\
["@standardnotes/common", "workspace:packages/common"],\
["@standardnotes/domain-core", "workspace:packages/domain-core"],\
["@standardnotes/encryption", "npm:1.21.38"],\
["@standardnotes/models", "npm:1.45.5"],\
["@standardnotes/responses", "npm:1.13.24"],\
["@standardnotes/models", "npm:1.46.7"],\
["@standardnotes/responses", "npm:1.13.26"],\
["@standardnotes/security", "workspace:packages/security"],\
["@standardnotes/utils", "npm:1.16.5"],\
["@standardnotes/utils", "npm:1.17.4"],\
["reflect-metadata", "npm:0.1.13"]\
],\
"linkType": "HARD"\
@ -4635,7 +4634,7 @@ const RAW_RUNTIME_STATE =
["@newrelic/winston-enricher", "virtual:c66bf20e88479ada0172094776519a9f51acc4731d22079b60a295bcec7ea42d5545cbce58a77a50d932bf953298799135e99707486e343da6d99ba1d167bdbd#npm:4.0.1"],\
["@simplewebauthn/server", "npm:7.2.0"],\
["@simplewebauthn/typescript-types", "npm:7.0.0"],\
["@standardnotes/api", "npm:1.26.10"],\
["@standardnotes/api", "npm:1.26.25"],\
["@standardnotes/common", "workspace:packages/common"],\
["@standardnotes/domain-core", "workspace:packages/domain-core"],\
["@standardnotes/domain-events", "workspace:packages/domain-events"],\
@ -4781,21 +4780,6 @@ const RAW_RUNTIME_STATE =
"linkType": "SOFT"\
}]\
]],\
["@standardnotes/encryption", [\
["npm:1.21.38", {\
"packageLocation": "./.yarn/cache/@standardnotes-encryption-npm-1.21.38-d08c3d4766-1393840523.zip/node_modules/@standardnotes/encryption/",\
"packageDependencies": [\
["@standardnotes/encryption", "npm:1.21.38"],\
["@standardnotes/common", "workspace:packages/common"],\
["@standardnotes/models", "npm:1.45.5"],\
["@standardnotes/responses", "npm:1.13.24"],\
["@standardnotes/sncrypto-common", "npm:1.13.3"],\
["@standardnotes/utils", "npm:1.16.5"],\
["reflect-metadata", "npm:0.1.13"]\
],\
"linkType": "HARD"\
}]\
]],\
["@standardnotes/event-store", [\
["workspace:packages/event-store", {\
"packageLocation": "./packages/event-store/",\
@ -4841,6 +4825,17 @@ const RAW_RUNTIME_STATE =
["reflect-metadata", "npm:0.1.13"]\
],\
"linkType": "HARD"\
}],\
["npm:1.59.6", {\
"packageLocation": "./.yarn/cache/@standardnotes-features-npm-1.59.6-2bcea0cc35-2c855396f7.zip/node_modules/@standardnotes/features/",\
"packageDependencies": [\
["@standardnotes/features", "npm:1.59.6"],\
["@standardnotes/common", "workspace:packages/common"],\
["@standardnotes/domain-core", "workspace:packages/domain-core"],\
["@standardnotes/security", "workspace:packages/security"],\
["reflect-metadata", "npm:0.1.13"]\
],\
"linkType": "HARD"\
}]\
]],\
["@standardnotes/files-server", [\
@ -4935,14 +4930,15 @@ const RAW_RUNTIME_STATE =
}]\
]],\
["@standardnotes/models", [\
["npm:1.45.5", {\
"packageLocation": "./.yarn/cache/@standardnotes-models-npm-1.45.5-29326e959c-15f26c11b2.zip/node_modules/@standardnotes/models/",\
["npm:1.46.7", {\
"packageLocation": "./.yarn/cache/@standardnotes-models-npm-1.46.7-ef9a3fc3ad-50589454f1.zip/node_modules/@standardnotes/models/",\
"packageDependencies": [\
["@standardnotes/models", "npm:1.45.5"],\
["@standardnotes/models", "npm:1.46.7"],\
["@standardnotes/common", "workspace:packages/common"],\
["@standardnotes/features", "npm:1.59.5"],\
["@standardnotes/responses", "npm:1.13.24"],\
["@standardnotes/utils", "npm:1.16.5"],\
["@standardnotes/features", "npm:1.59.6"],\
["@standardnotes/responses", "npm:1.13.26"],\
["@standardnotes/sncrypto-common", "npm:1.13.4"],\
["@standardnotes/utils", "npm:1.17.4"],\
["lodash", "npm:4.17.21"]\
],\
"linkType": "HARD"\
@ -4977,6 +4973,17 @@ const RAW_RUNTIME_STATE =
["reflect-metadata", "npm:0.1.13"]\
],\
"linkType": "HARD"\
}],\
["npm:1.13.26", {\
"packageLocation": "./.yarn/cache/@standardnotes-responses-npm-1.13.26-cd12940788-6c5e3bf896.zip/node_modules/@standardnotes/responses/",\
"packageDependencies": [\
["@standardnotes/responses", "npm:1.13.26"],\
["@standardnotes/common", "workspace:packages/common"],\
["@standardnotes/features", "npm:1.59.6"],\
["@standardnotes/security", "workspace:packages/security"],\
["reflect-metadata", "npm:0.1.13"]\
],\
"linkType": "HARD"\
}]\
]],\
["@standardnotes/revisions-server", [\
@ -4987,7 +4994,7 @@ const RAW_RUNTIME_STATE =
["@aws-sdk/client-s3", "npm:3.342.0"],\
["@aws-sdk/client-sqs", "npm:3.342.0"],\
["@newrelic/winston-enricher", "virtual:c66bf20e88479ada0172094776519a9f51acc4731d22079b60a295bcec7ea42d5545cbce58a77a50d932bf953298799135e99707486e343da6d99ba1d167bdbd#npm:4.0.1"],\
["@standardnotes/api", "npm:1.26.10"],\
["@standardnotes/api", "npm:1.26.25"],\
["@standardnotes/common", "workspace:packages/common"],\
["@standardnotes/domain-core", "workspace:packages/domain-core"],\
["@standardnotes/domain-events", "workspace:packages/domain-events"],\
@ -5136,6 +5143,14 @@ const RAW_RUNTIME_STATE =
["reflect-metadata", "npm:0.1.13"]\
],\
"linkType": "HARD"\
}],\
["npm:1.13.4", {\
"packageLocation": "./.yarn/cache/@standardnotes-sncrypto-common-npm-1.13.4-3186513fa6-48e0e207f2.zip/node_modules/@standardnotes/sncrypto-common/",\
"packageDependencies": [\
["@standardnotes/sncrypto-common", "npm:1.13.4"],\
["reflect-metadata", "npm:0.1.13"]\
],\
"linkType": "HARD"\
}]\
]],\
["@standardnotes/sncrypto-node", [\
@ -5171,7 +5186,7 @@ const RAW_RUNTIME_STATE =
["@aws-sdk/client-sns", "npm:3.342.0"],\
["@aws-sdk/client-sqs", "npm:3.342.0"],\
["@newrelic/winston-enricher", "virtual:c66bf20e88479ada0172094776519a9f51acc4731d22079b60a295bcec7ea42d5545cbce58a77a50d932bf953298799135e99707486e343da6d99ba1d167bdbd#npm:4.0.1"],\
["@standardnotes/api", "npm:1.26.10"],\
["@standardnotes/api", "npm:1.26.25"],\
["@standardnotes/common", "workspace:packages/common"],\
["@standardnotes/domain-core", "workspace:packages/domain-core"],\
["@standardnotes/domain-events", "workspace:packages/domain-events"],\
@ -5257,6 +5272,17 @@ const RAW_RUNTIME_STATE =
["reflect-metadata", "npm:0.1.13"]\
],\
"linkType": "HARD"\
}],\
["npm:1.17.4", {\
"packageLocation": "./.yarn/cache/@standardnotes-utils-npm-1.17.4-e5908cc204-7cb3fc838d.zip/node_modules/@standardnotes/utils/",\
"packageDependencies": [\
["@standardnotes/utils", "npm:1.17.4"],\
["@standardnotes/common", "workspace:packages/common"],\
["dompurify", "npm:2.4.5"],\
["lodash", "npm:4.17.21"],\
["reflect-metadata", "npm:0.1.13"]\
],\
"linkType": "HARD"\
}]\
]],\
["@standardnotes/websockets-server", [\
@ -5266,7 +5292,7 @@ const RAW_RUNTIME_STATE =
["@standardnotes/websockets-server", "workspace:packages/websockets"],\
["@aws-sdk/client-sqs", "npm:3.342.0"],\
["@newrelic/winston-enricher", "virtual:c66bf20e88479ada0172094776519a9f51acc4731d22079b60a295bcec7ea42d5545cbce58a77a50d932bf953298799135e99707486e343da6d99ba1d167bdbd#npm:4.0.1"],\
["@standardnotes/api", "npm:1.26.10"],\
["@standardnotes/api", "npm:1.26.25"],\
["@standardnotes/common", "workspace:packages/common"],\
["@standardnotes/domain-core", "workspace:packages/domain-core"],\
["@standardnotes/domain-events", "workspace:packages/domain-events"],\

View file

@ -42,7 +42,7 @@
"@cbor-extract/cbor-extract-linux-x64": "^2.1.1",
"@simplewebauthn/server": "^7.2.0",
"@simplewebauthn/typescript-types": "^7.0.0",
"@standardnotes/api": "^1.25.3",
"@standardnotes/api": "^1.26.25",
"@standardnotes/common": "workspace:*",
"@standardnotes/domain-core": "workspace:^",
"@standardnotes/domain-events": "workspace:*",

View file

@ -8,12 +8,12 @@ import { User } from '../Domain/User/User'
import { Register } from '../Domain/UseCase/Register'
import { DomainEventFactoryInterface } from '../Domain/Event/DomainEventFactoryInterface'
import { KeyParamsOrigination, ProtocolVersion } from '@standardnotes/common'
import { ApiVersion } from '@standardnotes/api'
import { SignInWithRecoveryCodes } from '../Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes'
import { GetUserKeyParamsRecovery } from '../Domain/UseCase/GetUserKeyParamsRecovery/GetUserKeyParamsRecovery'
import { GenerateRecoveryCodes } from '../Domain/UseCase/GenerateRecoveryCodes/GenerateRecoveryCodes'
import { Logger } from 'winston'
import { SessionServiceInterface } from '../Domain/Session/SessionServiceInterface'
import { ApiVersion } from '../Domain/Api/ApiVersion'
describe('AuthController', () => {
let clearLoginAttempts: ClearLoginAttempts
@ -73,7 +73,7 @@ describe('AuthController', () => {
email: 'test@test.te',
password: 'asdzxc',
version: ProtocolVersion.V004,
api: ApiVersion.v0,
api: ApiVersion.v20200115,
origination: KeyParamsOrigination.Registration,
userAgent: 'Google Chrome',
identifier: 'test@test.te',
@ -103,7 +103,7 @@ describe('AuthController', () => {
email: 'test@test.te',
password: '',
version: ProtocolVersion.V004,
api: ApiVersion.v0,
api: ApiVersion.v20200115,
origination: KeyParamsOrigination.Registration,
userAgent: 'Google Chrome',
identifier: 'test@test.te',
@ -123,7 +123,7 @@ describe('AuthController', () => {
email: 'test@test.te',
password: 'test',
version: ProtocolVersion.V004,
api: ApiVersion.v0,
api: ApiVersion.v20200115,
origination: KeyParamsOrigination.Registration,
userAgent: 'Google Chrome',
identifier: 'test@test.te',

View file

@ -1,10 +1,10 @@
import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
import {
ApiVersion,
UserRegistrationRequestParams,
UserServerInterface,
UserDeletionResponseBody,
UserRegistrationResponseBody,
UserUpdateRequestParams,
} from '@standardnotes/api'
import { ErrorTag, HttpResponse, HttpStatusCode } from '@standardnotes/responses'
import { ProtocolVersion } from '@standardnotes/common'
@ -23,6 +23,8 @@ import { GenerateRecoveryCodes } from '../Domain/UseCase/GenerateRecoveryCodes/G
import { GenerateRecoveryCodesRequestParams } from '../Infra/Http/Request/GenerateRecoveryCodesRequestParams'
import { Logger } from 'winston'
import { SessionServiceInterface } from '../Domain/Session/SessionServiceInterface'
import { ApiVersion } from '../Domain/Api/ApiVersion'
import { UserUpdateResponse } from '@standardnotes/api/dist/Domain/Response/User/UserUpdateResponse'
export class AuthController implements UserServerInterface {
constructor(
@ -37,6 +39,10 @@ export class AuthController implements UserServerInterface {
private sessionService: SessionServiceInterface,
) {}
async update(_params: UserUpdateRequestParams): Promise<HttpResponse<UserUpdateResponse>> {
throw new Error('Method not implemented.')
}
async deleteAccount(_params: never): Promise<HttpResponse<UserDeletionResponseBody>> {
throw new Error('This method is implemented on the payments server.')
}
@ -121,7 +127,7 @@ export class AuthController implements UserServerInterface {
async signInWithRecoveryCodes(
params: SignInWithRecoveryCodesRequestParams,
): Promise<HttpResponse<SignInWithRecoveryCodesResponseBody>> {
if (params.apiVersion !== ApiVersion.v0) {
if (params.apiVersion !== ApiVersion.v20200115) {
return {
status: HttpStatusCode.BadRequest,
data: {
@ -162,7 +168,7 @@ export class AuthController implements UserServerInterface {
async recoveryKeyParams(
params: RecoveryKeyParamsRequestParams,
): Promise<HttpResponse<RecoveryKeyParamsResponseBody>> {
if (params.apiVersion !== ApiVersion.v0) {
if (params.apiVersion !== ApiVersion.v20200115) {
return {
status: HttpStatusCode.BadRequest,
data: {

View file

@ -7,7 +7,7 @@ import { AcceptSharedSubscriptionInvitation } from '../Domain/UseCase/AcceptShar
import { DeclineSharedSubscriptionInvitation } from '../Domain/UseCase/DeclineSharedSubscriptionInvitation/DeclineSharedSubscriptionInvitation'
import { CancelSharedSubscriptionInvitation } from '../Domain/UseCase/CancelSharedSubscriptionInvitation/CancelSharedSubscriptionInvitation'
import { ListSharedSubscriptionInvitations } from '../Domain/UseCase/ListSharedSubscriptionInvitations/ListSharedSubscriptionInvitations'
import { ApiVersion } from '@standardnotes/api'
import { ApiVersion } from '../Domain/Api/ApiVersion'
describe('SubscriptionInvitesController', () => {
let inviteToSharedSubscription: InviteToSharedSubscription
@ -53,7 +53,7 @@ describe('SubscriptionInvitesController', () => {
invitations: [],
})
const result = await createController().listInvites({ api: ApiVersion.v0, inviterEmail: 'test@test.te' })
const result = await createController().listInvites({ api: ApiVersion.v20200115, inviterEmail: 'test@test.te' })
expect(listSharedSubscriptionInvitations.execute).toHaveBeenCalledWith({
inviterEmail: 'test@test.te',
@ -68,7 +68,7 @@ describe('SubscriptionInvitesController', () => {
})
const result = await createController().cancelInvite({
api: ApiVersion.v0,
api: ApiVersion.v20200115,
inviteUuid: '1-2-3',
inviterEmail: 'test@test.te',
})
@ -87,7 +87,7 @@ describe('SubscriptionInvitesController', () => {
})
const result = await createController().cancelInvite({
api: ApiVersion.v0,
api: ApiVersion.v20200115,
inviteUuid: '1-2-3',
})
@ -100,7 +100,7 @@ describe('SubscriptionInvitesController', () => {
})
const result = await createController().declineInvite({
api: ApiVersion.v0,
api: ApiVersion.v20200115,
inviteUuid: '1-2-3',
})
@ -117,7 +117,7 @@ describe('SubscriptionInvitesController', () => {
})
const result = await createController().declineInvite({
api: ApiVersion.v0,
api: ApiVersion.v20200115,
inviteUuid: '1-2-3',
})
@ -134,7 +134,7 @@ describe('SubscriptionInvitesController', () => {
})
const result = await createController().acceptInvite({
api: ApiVersion.v0,
api: ApiVersion.v20200115,
inviteUuid: '1-2-3',
})
@ -151,7 +151,7 @@ describe('SubscriptionInvitesController', () => {
})
const result = await createController().acceptInvite({
api: ApiVersion.v0,
api: ApiVersion.v20200115,
inviteUuid: '1-2-3',
})
@ -168,7 +168,7 @@ describe('SubscriptionInvitesController', () => {
})
const result = await createController().invite({
api: ApiVersion.v0,
api: ApiVersion.v20200115,
identifier: 'invitee@test.te',
inviterUuid: '1-2-3',
inviterEmail: 'test@test.te',
@ -187,7 +187,7 @@ describe('SubscriptionInvitesController', () => {
it('should not invite to user subscription if the identifier is missing in request', async () => {
const result = await createController().invite({
api: ApiVersion.v0,
api: ApiVersion.v20200115,
identifier: '',
inviterUuid: '1-2-3',
inviterEmail: 'test@test.te',
@ -205,7 +205,7 @@ describe('SubscriptionInvitesController', () => {
})
const result = await createController().invite({
api: ApiVersion.v0,
api: ApiVersion.v20200115,
identifier: 'invitee@test.te',
inviterUuid: '1-2-3',
inviterEmail: 'test@test.te',

View file

@ -1,6 +1,5 @@
import * as bcrypt from 'bcryptjs'
import { RoleName, Username } from '@standardnotes/domain-core'
import { ApiVersion } from '@standardnotes/api'
import { v4 as uuidv4 } from 'uuid'
import { inject, injectable } from 'inversify'
@ -16,6 +15,7 @@ import { TimerInterface } from '@standardnotes/time'
import { SettingServiceInterface } from '../Setting/SettingServiceInterface'
import { AuthResponseFactory20200115 } from '../Auth/AuthResponseFactory20200115'
import { AuthResponse20200115 } from '../Auth/AuthResponse20200115'
import { ApiVersion } from '../Api/ApiVersion'
@injectable()
export class Register implements UseCaseInterface {
@ -39,7 +39,7 @@ export class Register implements UseCaseInterface {
const { email, password, apiVersion, ephemeralSession, ...registrationFields } = dto
if (apiVersion !== ApiVersion.v0) {
if (apiVersion !== ApiVersion.v20200115) {
return {
success: false,
errorMessage: `Unsupported api version: ${apiVersion}`,

View file

@ -1,7 +1,6 @@
import * as bcrypt from 'bcryptjs'
import { Result, UseCaseInterface, Username, Uuid, Validator } from '@standardnotes/domain-core'
import { SettingName } from '@standardnotes/settings'
import { ApiVersion } from '@standardnotes/api'
import { AuthResponse20200115 } from '../../Auth/AuthResponse20200115'
import { SettingServiceInterface } from '../../Setting/SettingServiceInterface'
@ -16,6 +15,7 @@ import { IncreaseLoginAttempts } from '../IncreaseLoginAttempts'
import { ClearLoginAttempts } from '../ClearLoginAttempts'
import { DeleteSetting } from '../DeleteSetting/DeleteSetting'
import { AuthenticatorRepositoryInterface } from '../../Authenticator/AuthenticatorRepositoryInterface'
import { ApiVersion } from '../../Api/ApiVersion'
export class SignInWithRecoveryCodes implements UseCaseInterface<AuthResponse20200115> {
constructor(
@ -100,7 +100,7 @@ export class SignInWithRecoveryCodes implements UseCaseInterface<AuthResponse202
const authResponse = await this.authResponseFactory.createResponse({
user,
apiVersion: ApiVersion.v0,
apiVersion: ApiVersion.v20200115,
userAgent: dto.userAgent,
ephemeralSession: false,
readonlyAccess: false,

View file

@ -1,48 +0,0 @@
/* istanbul ignore file */
export enum ContentType {
Any = '*',
Item = 'SF|Item',
KeySystemItemsKey = 'SN|KeySystemItemsKey',
KeySystemRootKey = 'SN|KeySystemRootKey',
TrustedContact = 'SN|TrustedContact',
VaultListing = 'SN|VaultListing',
RootKey = 'SN|RootKey|NoSync',
ItemsKey = 'SN|ItemsKey',
EncryptedStorage = 'SN|EncryptedStorage',
Privileges = 'SN|Privileges',
Note = 'Note',
Tag = 'Tag',
SmartView = 'SN|SmartTag',
Component = 'SN|Component',
Editor = 'SN|Editor',
ActionsExtension = 'Extension',
UserPrefs = 'SN|UserPreferences',
HistorySession = 'SN|HistorySession',
Theme = 'SN|Theme',
File = 'SN|File',
FilesafeCredentials = 'SN|FileSafe|Credentials',
FilesafeFileMetadata = 'SN|FileSafe|FileMetadata',
FilesafeIntegration = 'SN|FileSafe|Integration',
ExtensionRepo = 'SN|ExtensionRepo',
Unknown = 'Unknown',
}
export function DisplayStringForContentType(contentType: ContentType): string | undefined {
const map: Partial<Record<ContentType, string>> = {
[ContentType.ActionsExtension]: 'action-based extension',
[ContentType.Component]: 'component',
[ContentType.Editor]: 'editor',
[ContentType.File]: 'file',
[ContentType.FilesafeCredentials]: 'FileSafe credential',
[ContentType.FilesafeFileMetadata]: 'FileSafe file',
[ContentType.FilesafeIntegration]: 'FileSafe integration',
[ContentType.ItemsKey]: 'encryption key',
[ContentType.Note]: 'note',
[ContentType.SmartView]: 'smart view',
[ContentType.Tag]: 'tag',
[ContentType.Theme]: 'theme',
[ContentType.UserPrefs]: 'user preferences',
}
return map[contentType]
}

View file

@ -1,4 +1,3 @@
export * from './Content/ContentType'
export * from './Content/ContentDecoder'
export * from './Content/ContentDecoderInterface'
export * from './DataType/AnyRecord'

View file

@ -0,0 +1,39 @@
import { ContentType } from './ContentType'
describe('ContentType', () => {
it('should create a value object', () => {
const valueOrError = ContentType.create(ContentType.TYPES.Component)
expect(valueOrError.isFailed()).toBeFalsy()
expect(valueOrError.getValue().value).toEqual('SN|Component')
})
it('should not create an invalid value object', () => {
for (const value of ['', undefined, 0, 'FOOBAR']) {
const valueOrError = ContentType.create(value as string)
expect(valueOrError.isFailed()).toBeTruthy()
}
})
it('should return a display name', () => {
const valueOrError = ContentType.create(ContentType.TYPES.FilesafeFileMetadata)
expect(valueOrError.isFailed()).toBeFalsy()
expect(valueOrError.getValue().getDisplayName()).toEqual('FileSafe file')
})
it('should return null for a display name if the value is null', () => {
const valueOrError = ContentType.create(null)
expect(valueOrError.isFailed()).toBeFalsy()
expect(valueOrError.getValue().getDisplayName()).toBeNull()
})
it('should fallback to the value if the display name is not found', () => {
const valueOrError = ContentType.create(ContentType.TYPES.Unknown)
expect(valueOrError.isFailed()).toBeFalsy()
expect(valueOrError.getValue().getDisplayName()).toEqual('Unknown')
})
})

View file

@ -0,0 +1,79 @@
import { Result } from '../Core/Result'
import { ValueObject } from '../Core/ValueObject'
import { ContentTypeProps } from './ContentTypeProps'
export class ContentType extends ValueObject<ContentTypeProps> {
static readonly TYPES = {
Any: '*',
Item: 'SF|Item',
KeySystemItemsKey: 'SN|KeySystemItemsKey',
KeySystemRootKey: 'SN|KeySystemRootKey',
TrustedContact: 'SN|TrustedContact',
VaultListing: 'SN|VaultListing',
RootKey: 'SN|RootKey|NoSync',
ItemsKey: 'SN|ItemsKey',
EncryptedStorage: 'SN|EncryptedStorage',
Privileges: 'SN|Privileges',
Note: 'Note',
Tag: 'Tag',
SmartView: 'SN|SmartTag',
Component: 'SN|Component',
Editor: 'SN|Editor',
ActionsExtension: 'Extension',
UserPrefs: 'SN|UserPreferences',
HistorySession: 'SN|HistorySession',
Theme: 'SN|Theme',
File: 'SN|File',
FilesafeCredentials: 'SN|FileSafe|Credentials',
FilesafeFileMetadata: 'SN|FileSafe|FileMetadata',
FilesafeIntegration: 'SN|FileSafe|Integration',
ExtensionRepo: 'SN|ExtensionRepo',
Unknown: 'Unknown',
}
private readonly displayNamesMap: Partial<Record<string, string>> = {
[ContentType.TYPES.ActionsExtension]: 'action-based extension',
[ContentType.TYPES.Component]: 'component',
[ContentType.TYPES.Editor]: 'editor',
[ContentType.TYPES.File]: 'file',
[ContentType.TYPES.FilesafeCredentials]: 'FileSafe credential',
[ContentType.TYPES.FilesafeFileMetadata]: 'FileSafe file',
[ContentType.TYPES.FilesafeIntegration]: 'FileSafe integration',
[ContentType.TYPES.ItemsKey]: 'encryption key',
[ContentType.TYPES.Note]: 'note',
[ContentType.TYPES.SmartView]: 'smart view',
[ContentType.TYPES.Tag]: 'tag',
[ContentType.TYPES.Theme]: 'theme',
[ContentType.TYPES.UserPrefs]: 'user preferences',
}
get value(): string | null {
return this.props.value
}
private constructor(props: ContentTypeProps) {
super(props)
}
static create(type: string | null): Result<ContentType> {
if (type === null) {
return Result.ok<ContentType>(new ContentType({ value: null }))
}
const isValidType = Object.values(this.TYPES).includes(type)
if (!isValidType) {
return Result.fail<ContentType>(`Invalid content type: ${type}`)
} else {
return Result.ok<ContentType>(new ContentType({ value: type }))
}
}
getDisplayName(): string | null {
if (!this.value) {
return null
}
return this.displayNamesMap[this.value] || this.value
}
}

View file

@ -9,6 +9,8 @@ export * from './Cache/CacheEntry'
export * from './Cache/CacheEntryProps'
export * from './Cache/CacheEntryRepositoryInterface'
export * from './Common/ContentType'
export * from './Common/ContentTypeProps'
export * from './Common/Dates'
export * from './Common/DatesProps'
export * from './Common/Email'

View file

@ -27,7 +27,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.332.0",
"@aws-sdk/client-sqs": "^3.332.0",
"@standardnotes/api": "^1.25.3",
"@standardnotes/api": "^1.26.25",
"@standardnotes/common": "workspace:^",
"@standardnotes/domain-core": "workspace:^",
"@standardnotes/domain-events": "workspace:*",

View file

@ -1,16 +0,0 @@
import { ContentType } from './ContentType'
describe('ContentType', () => {
it('should create a value obejct', () => {
const valueOrError = ContentType.create('Note')
expect(valueOrError.isFailed()).toBeFalsy()
expect(valueOrError.getValue().value).not.toBeNull()
})
it('should fail to create a value obejct', () => {
const valueOrError = ContentType.create('test')
expect(valueOrError.isFailed()).toBeTruthy()
})
})

View file

@ -1,22 +0,0 @@
import { ContentType as ContentTypeValues } from '@standardnotes/common'
import { Result, ValueObject } from '@standardnotes/domain-core'
import { ContentTypeProps } from './ContentTypeProps'
export class ContentType extends ValueObject<ContentTypeProps> {
get value(): string | null {
return this.props.value
}
private constructor(props: ContentTypeProps) {
super(props)
}
static create(contentType: string | null): Result<ContentType> {
if (contentType !== null && !Object.values(ContentTypeValues).includes(contentType as ContentTypeValues)) {
return Result.fail<ContentType>(`Value is not a valid content type: ${contentType}`)
} else {
return Result.ok<ContentType>(new ContentType({ value: contentType }))
}
}
}

View file

@ -1,5 +1,5 @@
import { Dates, Uuid } from '@standardnotes/domain-core'
import { ContentType } from './ContentType'
import { ContentType, Dates, Uuid } from '@standardnotes/domain-core'
import { Revision } from './Revision'
describe('Revision', () => {

View file

@ -1,6 +1,4 @@
import { Dates } from '@standardnotes/domain-core'
import { ContentType } from './ContentType'
import { ContentType, Dates } from '@standardnotes/domain-core'
export interface RevisionMetadataProps {
contentType: ContentType

View file

@ -1,6 +1,4 @@
import { Dates, Uuid } from '@standardnotes/domain-core'
import { ContentType } from './ContentType'
import { ContentType, Dates, Uuid } from '@standardnotes/domain-core'
export interface RevisionProps {
itemUuid: Uuid

View file

@ -1,6 +1,5 @@
import { MapperInterface, Dates, Uuid } from '@standardnotes/domain-core'
import { MapperInterface, Dates, Uuid, ContentType } from '@standardnotes/domain-core'
import { ContentType } from '../Domain/Revision/ContentType'
import { Revision } from '../Domain/Revision/Revision'
export class RevisionItemStringMapper implements MapperInterface<Revision, string> {

View file

@ -1,6 +1,5 @@
import { MapperInterface, Dates, UniqueEntityId } from '@standardnotes/domain-core'
import { MapperInterface, Dates, UniqueEntityId, ContentType } from '@standardnotes/domain-core'
import { ContentType } from '../Domain/Revision/ContentType'
import { RevisionMetadata } from '../Domain/Revision/RevisionMetadata'
import { TypeORMRevision } from '../Infra/TypeORM/TypeORMRevision'

View file

@ -1,5 +1,4 @@
import { MapperInterface, Dates, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
import { ContentType } from '../Domain/Revision/ContentType'
import { MapperInterface, Dates, UniqueEntityId, Uuid, ContentType } from '@standardnotes/domain-core'
import { Revision } from '../Domain/Revision/Revision'
import { TypeORMRevision } from '../Infra/TypeORM/TypeORMRevision'

View file

@ -31,7 +31,7 @@
"@aws-sdk/client-s3": "^3.332.0",
"@aws-sdk/client-sns": "^3.332.0",
"@aws-sdk/client-sqs": "^3.332.0",
"@standardnotes/api": "^1.25.3",
"@standardnotes/api": "^1.26.25",
"@standardnotes/common": "workspace:*",
"@standardnotes/domain-core": "workspace:^",
"@standardnotes/domain-events": "workspace:*",

View file

@ -9,9 +9,6 @@ import { ItemRepositoryInterface } from '../Domain/Item/ItemRepositoryInterface'
import { TypeORMItemRepository } from '../Infra/TypeORM/TypeORMItemRepository'
import { Repository } from 'typeorm'
import { Item } from '../Domain/Item/Item'
import { ItemProjection } from '../Projection/ItemProjection'
import { ProjectorInterface } from '../Projection/ProjectorInterface'
import { ItemProjector } from '../Projection/ItemProjector'
import {
DirectCallDomainEventPublisher,
DirectCallEventMessageHandler,
@ -26,15 +23,12 @@ 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 { ItemFactory } from '../Domain/Item/ItemFactory'
import { ItemFactoryInterface } from '../Domain/Item/ItemFactoryInterface'
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'
import { TimeDifferenceFilter } from '../Domain/Item/SaveRule/TimeDifferenceFilter'
import { UuidFilter } from '../Domain/Item/SaveRule/UuidFilter'
import { ItemSaveValidator } from '../Domain/Item/SaveValidator/ItemSaveValidator'
import { ItemSaveValidatorInterface } from '../Domain/Item/SaveValidator/ItemSaveValidatorInterface'
import { SyncResponseFactory20161215 } from '../Domain/Item/SyncResponse/SyncResponseFactory20161215'
@ -45,10 +39,6 @@ import { CheckIntegrity } from '../Domain/UseCase/Syncing/CheckIntegrity/CheckIn
import { GetItem } from '../Domain/UseCase/Syncing/GetItem/GetItem'
import { SyncItems } from '../Domain/UseCase/Syncing/SyncItems/SyncItems'
import { InversifyExpressAuthMiddleware } from '../Infra/InversifyExpressUtils/Middleware/InversifyExpressAuthMiddleware'
import { ItemConflictProjection } from '../Projection/ItemConflictProjection'
import { ItemConflictProjector } from '../Projection/ItemConflictProjector'
import { SavedItemProjection } from '../Projection/SavedItemProjection'
import { SavedItemProjector } from '../Projection/SavedItemProjector'
import { S3Client } from '@aws-sdk/client-s3'
import { SQSClient, SQSClientConfig } from '@aws-sdk/client-sqs'
import { ContentDecoder } from '@standardnotes/common'
@ -70,9 +60,21 @@ import { ItemBackupServiceInterface } from '../Domain/Item/ItemBackupServiceInte
import { FSItemBackupService } from '../Infra/FS/FSItemBackupService'
import { AuthHttpService } from '../Infra/HTTP/AuthHttpService'
import { S3ItemBackupService } from '../Infra/S3/S3ItemBackupService'
import { ControllerContainer, ControllerContainerInterface } from '@standardnotes/domain-core'
import { ControllerContainer, ControllerContainerInterface, MapperInterface } from '@standardnotes/domain-core'
import { HomeServerItemsController } from '../Infra/InversifyExpressUtils/HomeServer/HomeServerItemsController'
import { Transform } from 'stream'
import { TypeORMItem } from '../Infra/TypeORM/TypeORMItem'
import { ItemPersistenceMapper } from '../Mapping/Persistence/ItemPersistenceMapper'
import { ItemHttpRepresentation } from '../Mapping/Http/ItemHttpRepresentation'
import { ItemHttpMapper } from '../Mapping/Http/ItemHttpMapper'
import { SavedItemHttpRepresentation } from '../Mapping/Http/SavedItemHttpRepresentation'
import { SavedItemHttpMapper } from '../Mapping/Http/SavedItemHttpMapper'
import { ItemConflictHttpRepresentation } from '../Mapping/Http/ItemConflictHttpRepresentation'
import { ItemConflictHttpMapper } from '../Mapping/Http/ItemConflictHttpMapper'
import { ItemBackupRepresentation } from '../Mapping/Backup/ItemBackupRepresentation'
import { ItemBackupMapper } from '../Mapping/Backup/ItemBackupMapper'
import { SaveNewItem } from '../Domain/UseCase/Syncing/SaveNewItem/SaveNewItem'
import { UpdateExistingItem } from '../Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem'
export class ContainerConfigLoader {
private readonly DEFAULT_CONTENT_SIZE_TRANSFER_LIMIT = 10_000_000
@ -122,6 +124,8 @@ export class ContainerConfigLoader {
logger.debug('Database initialized')
container.bind<TimerInterface>(TYPES.Sync_Timer).toConstantValue(new Timer())
const isConfiguredForHomeServer = env.get('MODE', true) === 'home-server'
container.bind<Env>(TYPES.Sync_Env).toConstantValue(env)
@ -201,24 +205,37 @@ export class ContainerConfigLoader {
})
}
// Repositories
container.bind<ItemRepositoryInterface>(TYPES.Sync_ItemRepository).toDynamicValue((context: interfaces.Context) => {
return new TypeORMItemRepository(context.container.get(TYPES.Sync_ORMItemRepository))
})
// Mapping
container
.bind<MapperInterface<Item, TypeORMItem>>(TYPES.Sync_ItemPersistenceMapper)
.toConstantValue(new ItemPersistenceMapper())
container
.bind<MapperInterface<Item, ItemHttpRepresentation>>(TYPES.Sync_ItemHttpMapper)
.toConstantValue(new ItemHttpMapper(container.get(TYPES.Sync_Timer)))
container
.bind<MapperInterface<Item, SavedItemHttpRepresentation>>(TYPES.Sync_SavedItemHttpMapper)
.toConstantValue(new SavedItemHttpMapper(container.get(TYPES.Sync_Timer)))
container
.bind<MapperInterface<ItemConflict, ItemConflictHttpRepresentation>>(TYPES.Sync_ItemConflictHttpMapper)
.toConstantValue(new ItemConflictHttpMapper(container.get(TYPES.Sync_ItemHttpMapper)))
container
.bind<MapperInterface<Item, ItemBackupRepresentation>>(TYPES.Sync_ItemBackupMapper)
.toConstantValue(new ItemBackupMapper(container.get(TYPES.Sync_Timer)))
// ORM
container
.bind<Repository<Item>>(TYPES.Sync_ORMItemRepository)
.toDynamicValue(() => appDataSource.getRepository(Item))
.bind<Repository<TypeORMItem>>(TYPES.Sync_ORMItemRepository)
.toDynamicValue(() => appDataSource.getRepository(TypeORMItem))
// Projectors
// Repositories
container
.bind<ProjectorInterface<Item, ItemProjection>>(TYPES.Sync_ItemProjector)
.toDynamicValue((context: interfaces.Context) => {
return new ItemProjector(context.container.get(TYPES.Sync_Timer))
})
container.bind<TimerInterface>(TYPES.Sync_Timer).toDynamicValue(() => new Timer())
.bind<ItemRepositoryInterface>(TYPES.Sync_ItemRepository)
.toConstantValue(
new TypeORMItemRepository(
container.get(TYPES.Sync_ORMItemRepository),
container.get(TYPES.Sync_ItemPersistenceMapper),
),
)
container
.bind<DomainEventFactoryInterface>(TYPES.Sync_DomainEventFactory)
@ -245,18 +262,6 @@ export class ContainerConfigLoader {
)
})
// Projectors
container
.bind<ProjectorInterface<Item, SavedItemProjection>>(TYPES.Sync_SavedItemProjector)
.toDynamicValue((context: interfaces.Context) => {
return new SavedItemProjector(context.container.get(TYPES.Sync_Timer))
})
container
.bind<ProjectorInterface<ItemConflict, ItemConflictProjection>>(TYPES.Sync_ItemConflictProjector)
.toDynamicValue((context: interfaces.Context) => {
return new ItemConflictProjector(context.container.get(TYPES.Sync_ItemProjector))
})
// env vars
container.bind(TYPES.Sync_AUTH_JWT_SECRET).toConstantValue(env.get('AUTH_JWT_SECRET'))
container
@ -287,60 +292,35 @@ export class ContainerConfigLoader {
container.bind<GetItem>(TYPES.Sync_GetItem).toDynamicValue((context: interfaces.Context) => {
return new GetItem(context.container.get(TYPES.Sync_ItemRepository))
})
container
.bind<SaveNewItem>(TYPES.Sync_SaveNewItem)
.toConstantValue(
new SaveNewItem(
container.get(TYPES.Sync_ItemRepository),
container.get(TYPES.Sync_Timer),
container.get(TYPES.Sync_DomainEventPublisher),
container.get(TYPES.Sync_DomainEventFactory),
),
)
container
.bind<UpdateExistingItem>(TYPES.Sync_UpdateExistingItem)
.toConstantValue(
new UpdateExistingItem(
container.get(TYPES.Sync_ItemRepository),
container.get(TYPES.Sync_Timer),
container.get(TYPES.Sync_DomainEventPublisher),
container.get(TYPES.Sync_DomainEventFactory),
container.get(TYPES.Sync_REVISIONS_FREQUENCY),
),
)
// Services
container.bind<ItemServiceInterface>(TYPES.Sync_ItemService).toDynamicValue((context: interfaces.Context) => {
return new ItemService(
context.container.get(TYPES.Sync_ItemSaveValidator),
context.container.get(TYPES.Sync_ItemFactory),
context.container.get(TYPES.Sync_ItemRepository),
context.container.get(TYPES.Sync_DomainEventPublisher),
context.container.get(TYPES.Sync_DomainEventFactory),
context.container.get(TYPES.Sync_REVISIONS_FREQUENCY),
context.container.get(TYPES.Sync_CONTENT_SIZE_TRANSFER_LIMIT),
context.container.get(TYPES.Sync_ItemTransferCalculator),
context.container.get(TYPES.Sync_Timer),
context.container.get(TYPES.Sync_ItemProjector),
context.container.get(TYPES.Sync_MAX_ITEMS_LIMIT),
context.container.get(TYPES.Sync_Logger),
)
})
container
.bind<SyncResponseFactory20161215>(TYPES.Sync_SyncResponseFactory20161215)
.toDynamicValue((context: interfaces.Context) => {
return new SyncResponseFactory20161215(context.container.get(TYPES.Sync_ItemProjector))
})
container
.bind<SyncResponseFactory20200115>(TYPES.Sync_SyncResponseFactory20200115)
.toDynamicValue((context: interfaces.Context) => {
return new SyncResponseFactory20200115(
context.container.get(TYPES.Sync_ItemProjector),
context.container.get(TYPES.Sync_ItemConflictProjector),
context.container.get(TYPES.Sync_SavedItemProjector),
)
})
container
.bind<SyncResponseFactoryResolverInterface>(TYPES.Sync_SyncResponseFactoryResolver)
.toDynamicValue((context: interfaces.Context) => {
return new SyncResponseFactoryResolver(
context.container.get(TYPES.Sync_SyncResponseFactory20161215),
context.container.get(TYPES.Sync_SyncResponseFactory20200115),
)
})
container.bind<ItemFactoryInterface>(TYPES.Sync_ItemFactory).toDynamicValue((context: interfaces.Context) => {
return new ItemFactory(context.container.get(TYPES.Sync_Timer), context.container.get(TYPES.Sync_ItemProjector))
})
container.bind<OwnershipFilter>(TYPES.Sync_OwnershipFilter).toDynamicValue(() => new OwnershipFilter())
container.bind<OwnershipFilter>(TYPES.Sync_OwnershipFilter).toConstantValue(new OwnershipFilter())
container
.bind<TimeDifferenceFilter>(TYPES.Sync_TimeDifferenceFilter)
.toDynamicValue(
(context: interfaces.Context) => new TimeDifferenceFilter(context.container.get(TYPES.Sync_Timer)),
)
container.bind<UuidFilter>(TYPES.Sync_UuidFilter).toDynamicValue(() => new UuidFilter())
container.bind<ContentTypeFilter>(TYPES.Sync_ContentTypeFilter).toDynamicValue(() => new ContentTypeFilter())
container.bind<ContentFilter>(TYPES.Sync_ContentFilter).toDynamicValue(() => new ContentFilter())
.toConstantValue(new TimeDifferenceFilter(container.get(TYPES.Sync_Timer)))
container.bind<ContentTypeFilter>(TYPES.Sync_ContentTypeFilter).toConstantValue(new ContentTypeFilter())
container.bind<ContentFilter>(TYPES.Sync_ContentFilter).toConstantValue(new ContentFilter())
container
.bind<ItemSaveValidatorInterface>(TYPES.Sync_ItemSaveValidator)
@ -348,12 +328,47 @@ export class ContainerConfigLoader {
return new ItemSaveValidator([
context.container.get(TYPES.Sync_OwnershipFilter),
context.container.get(TYPES.Sync_TimeDifferenceFilter),
context.container.get(TYPES.Sync_UuidFilter),
context.container.get(TYPES.Sync_ContentTypeFilter),
context.container.get(TYPES.Sync_ContentFilter),
])
})
container
.bind<ItemServiceInterface>(TYPES.Sync_ItemService)
.toConstantValue(
new ItemService(
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<SyncResponseFactory20161215>(TYPES.Sync_SyncResponseFactory20161215)
.toConstantValue(new SyncResponseFactory20161215(container.get(TYPES.Sync_ItemHttpMapper)))
container
.bind<SyncResponseFactory20200115>(TYPES.Sync_SyncResponseFactory20200115)
.toConstantValue(
new SyncResponseFactory20200115(
container.get(TYPES.Sync_ItemHttpMapper),
container.get(TYPES.Sync_ItemConflictHttpMapper),
container.get(TYPES.Sync_SavedItemHttpMapper),
),
)
container
.bind<SyncResponseFactoryResolverInterface>(TYPES.Sync_SyncResponseFactoryResolver)
.toDynamicValue((context: interfaces.Context) => {
return new SyncResponseFactoryResolver(
context.container.get(TYPES.Sync_SyncResponseFactory20161215),
context.container.get(TYPES.Sync_SyncResponseFactory20200115),
)
})
// env vars
container
.bind(TYPES.Sync_EMAIL_ATTACHMENT_MAX_BYTE_SIZE)
@ -421,14 +436,15 @@ export class ContainerConfigLoader {
if (env.get('S3_AWS_REGION', true)) {
return new S3ItemBackupService(
context.container.get(TYPES.Sync_S3_BACKUP_BUCKET_NAME),
context.container.get(TYPES.Sync_ItemProjector),
context.container.get(TYPES.Sync_ItemBackupMapper),
context.container.get(TYPES.Sync_ItemHttpMapper),
context.container.get(TYPES.Sync_Logger),
context.container.get(TYPES.Sync_S3),
)
} else {
return new FSItemBackupService(
context.container.get(TYPES.Sync_FILE_UPLOAD_PATH),
context.container.get(TYPES.Sync_ItemProjector),
context.container.get(TYPES.Sync_ItemBackupMapper),
context.container.get(TYPES.Sync_Logger),
)
}
@ -512,7 +528,7 @@ export class ContainerConfigLoader {
container.get(TYPES.Sync_SyncItems),
container.get(TYPES.Sync_CheckIntegrity),
container.get(TYPES.Sync_GetItem),
container.get(TYPES.Sync_ItemProjector),
container.get(TYPES.Sync_ItemHttpMapper),
container.get(TYPES.Sync_SyncResponseFactoryResolver),
container.get(TYPES.Sync_ControllerContainer),
),

View file

@ -1,9 +1,9 @@
import { DataSource, EntityTarget, LoggerOptions, ObjectLiteral, Repository } from 'typeorm'
import { MysqlConnectionOptions } from 'typeorm/driver/mysql/MysqlConnectionOptions'
import { Item } from '../Domain/Item/Item'
import { Notification } from '../Domain/Notifications/Notification'
import { Env } from './Env'
import { SqliteConnectionOptions } from 'typeorm/driver/sqlite/SqliteConnectionOptions'
import { TypeORMItem } from '../Infra/TypeORM/TypeORMItem'
import { TypeORMNotification } from '../Infra/TypeORM/TypeORMNotification'
export class AppDataSource {
private _dataSource: DataSource | undefined
@ -33,7 +33,7 @@ export class AppDataSource {
const commonDataSourceOptions = {
maxQueryExecutionTime,
entities: [Item, Notification],
entities: [TypeORMItem, TypeORMNotification],
migrations: [`${__dirname}/../../migrations/${isConfiguredForMySQL ? 'mysql' : 'sqlite'}/*.js`],
migrationsRun: true,
logging: <LoggerOptions>this.env.get('DB_DEBUG_LEVEL', true) ?? 'info',

View file

@ -12,10 +12,6 @@ const TYPES = {
Sync_ORMItemRepository: Symbol.for('Sync_ORMItemRepository'),
// Middleware
Sync_AuthMiddleware: Symbol.for('Sync_AuthMiddleware'),
// Projectors
Sync_ItemProjector: Symbol.for('Sync_ItemProjector'),
Sync_SavedItemProjector: Symbol.for('Sync_SavedItemProjector'),
Sync_ItemConflictProjector: Symbol.for('Sync_ItemConflictProjector'),
// env vars
Sync_REDIS_URL: Symbol.for('Sync_REDIS_URL'),
Sync_SNS_TOPIC_ARN: Symbol.for('Sync_SNS_TOPIC_ARN'),
@ -57,6 +53,8 @@ const TYPES = {
Sync_SendMessageToUser: Symbol.for('Sync_SendMessageToUser'),
Sync_DeleteAllMessagesSentToUser: Symbol.for('Sync_DeleteAllMessagesSentToUser'),
Sync_DeleteMessage: Symbol.for('Sync_DeleteMessage'),
Sync_SaveNewItem: Symbol.for('Sync_SaveNewItem'),
Sync_UpdateExistingItem: Symbol.for('Sync_UpdateExistingItem'),
// Handlers
Sync_AccountDeletionRequestedEventHandler: Symbol.for('Sync_AccountDeletionRequestedEventHandler'),
Sync_DuplicateItemSyncedEventHandler: Symbol.for('Sync_DuplicateItemSyncedEventHandler'),
@ -80,10 +78,8 @@ const TYPES = {
Sync_ItemSaveValidator: Symbol.for('Sync_ItemSaveValidator'),
Sync_OwnershipFilter: Symbol.for('Sync_OwnershipFilter'),
Sync_TimeDifferenceFilter: Symbol.for('Sync_TimeDifferenceFilter'),
Sync_UuidFilter: Symbol.for('Sync_UuidFilter'),
Sync_ContentTypeFilter: Symbol.for('Sync_ContentTypeFilter'),
Sync_ContentFilter: Symbol.for('Sync_ContentFilter'),
Sync_ItemFactory: Symbol.for('Sync_ItemFactory'),
Sync_ItemTransferCalculator: Symbol.for('Sync_ItemTransferCalculator'),
Sync_ControllerContainer: Symbol.for('Sync_ControllerContainer'),
Sync_HomeServerItemsController: Symbol.for('Sync_HomeServerItemsController'),
@ -92,6 +88,11 @@ const TYPES = {
Sync_SharedVaultUserHttpMapper: Symbol.for('Sync_SharedVaultUserHttpMapper'),
Sync_SharedVaultInviteHttpMapper: Symbol.for('Sync_SharedVaultInviteHttpMapper'),
Sync_MessageHttpMapper: Symbol.for('Sync_MessageHttpMapper'),
Sync_ItemPersistenceMapper: Symbol.for('Sync_ItemPersistenceMapper'),
Sync_ItemHttpMapper: Symbol.for('Sync_ItemHttpMapper'),
Sync_SavedItemHttpMapper: Symbol.for('Sync_SavedItemHttpMapper'),
Sync_ItemConflictHttpMapper: Symbol.for('Sync_ItemConflictHttpMapper'),
Sync_ItemBackupMapper: Symbol.for('Sync_ItemBackupMapper'),
}
export default TYPES

View file

@ -9,6 +9,7 @@ import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface'
import { ExtensionsHttpService } from './ExtensionsHttpService'
import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface'
import { AxiosInstance } from 'axios'
import { Uuid, ContentType, Dates, Timestamps, UniqueEntityId } from '@standardnotes/domain-core'
describe('ExtensionsHttpService', () => {
let httpClient: AxiosInstance
@ -34,9 +35,22 @@ describe('ExtensionsHttpService', () => {
httpClient = {} as jest.Mocked<AxiosInstance>
httpClient.request = jest.fn().mockReturnValue({ status: 200, data: { foo: 'bar' } })
item = {
content: 'test',
} as jest.Mocked<Item>
item = Item.create(
{
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
updatedWithSession: null,
content: 'foobar',
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()
authParams = {} as jest.Mocked<KeyParamsData>

View file

@ -140,11 +140,11 @@ export class ExtensionsHttpService implements ExtensionsHttpServiceInterface {
email: string,
): Promise<DomainEventInterface> {
const extension = await this.itemRepository.findByUuidAndUserUuid(extensionId, userUuid)
if (extension === null || !extension.content) {
if (extension === null || !extension.props.content) {
throw Error(`Could not find extensions with id ${extensionId}`)
}
const content = this.contentDecoder.decode(extension.content)
const content = this.contentDecoder.decode(extension.props.content)
switch (this.getExtensionName(content)) {
case ExtensionName.Dropbox:
return this.createCloudBackupFailedEventBasedOnProvider('DROPBOX', email)

View file

@ -5,6 +5,7 @@ import { Logger } from 'winston'
import { Item } from '../Item/Item'
import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface'
import { AccountDeletionRequestedEventHandler } from './AccountDeletionRequestedEventHandler'
import { Uuid, ContentType, Dates, Timestamps, UniqueEntityId } from '@standardnotes/domain-core'
describe('AccountDeletionRequestedEventHandler', () => {
let itemRepository: ItemRepositoryInterface
@ -15,10 +16,22 @@ describe('AccountDeletionRequestedEventHandler', () => {
const createHandler = () => new AccountDeletionRequestedEventHandler(itemRepository, logger)
beforeEach(() => {
item = {
uuid: '1-2-3',
content: 'test',
} as jest.Mocked<Item>
item = Item.create(
{
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
updatedWithSession: null,
content: 'foobar',
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()
itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
itemRepository.findAll = jest.fn().mockReturnValue([item])

View file

@ -10,6 +10,7 @@ import { Item } from '../Item/Item'
import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface'
import { DuplicateItemSyncedEventHandler } from './DuplicateItemSyncedEventHandler'
import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface'
import { Uuid, ContentType, Dates, Timestamps, UniqueEntityId } from '@standardnotes/domain-core'
describe('DuplicateItemSyncedEventHandler', () => {
let itemRepository: ItemRepositoryInterface
@ -24,14 +25,39 @@ describe('DuplicateItemSyncedEventHandler', () => {
new DuplicateItemSyncedEventHandler(itemRepository, domainEventFactory, domainEventPublisher, logger)
beforeEach(() => {
originalItem = {
uuid: '1-2-3',
} as jest.Mocked<Item>
originalItem = Item.create(
{
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
updatedWithSession: null,
content: 'foobar',
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()
duplicateItem = {
uuid: '2-3-4',
duplicateOf: '1-2-3',
} as jest.Mocked<Item>
duplicateItem = Item.create(
{
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
updatedWithSession: null,
content: 'foobar',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: null,
authHash: null,
itemsKeyId: null,
duplicateOf: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
deleted: false,
dates: Dates.create(new Date(1616164633241311), new Date(1616164633241311)).getValue(),
timestamps: Timestamps.create(1616164633241311, 1616164633241311).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000001'),
).getValue()
itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
itemRepository.findByUuidAndUserUuid = jest
@ -81,7 +107,7 @@ describe('DuplicateItemSyncedEventHandler', () => {
})
it('should not copy revisions if duplicate item is not pointing to duplicate anything', async () => {
duplicateItem.duplicateOf = null
duplicateItem.props.duplicateOf = null
await createHandler().handle(event)
expect(domainEventPublisher.publish).not.toHaveBeenCalled()

View file

@ -24,22 +24,22 @@ export class DuplicateItemSyncedEventHandler implements DomainEventHandlerInterf
return
}
if (!item.duplicateOf) {
if (!item.props.duplicateOf) {
this.logger.warn(`Item ${event.payload.itemUuid} does not point to any duplicate`)
return
}
const existingOriginalItem = await this.itemRepository.findByUuidAndUserUuid(
item.duplicateOf,
item.props.duplicateOf.value,
event.payload.userUuid,
)
if (existingOriginalItem !== null) {
await this.domainEventPublisher.publish(
this.domainEventFactory.createRevisionsCopyRequestedEvent(event.payload.userUuid, {
originalItemUuid: existingOriginalItem.uuid,
newItemUuid: item.uuid,
originalItemUuid: existingOriginalItem.id.toString(),
newItemUuid: item.id.toString(),
}),
)
}

View file

@ -11,6 +11,7 @@ import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface'
import { ItemRevisionCreationRequestedEventHandler } from './ItemRevisionCreationRequestedEventHandler'
import { ItemBackupServiceInterface } from '../Item/ItemBackupServiceInterface'
import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface'
import { Uuid, ContentType, Dates, Timestamps, UniqueEntityId } from '@standardnotes/domain-core'
describe('ItemRevisionCreationRequestedEventHandler', () => {
let itemRepository: ItemRepositoryInterface
@ -29,10 +30,22 @@ describe('ItemRevisionCreationRequestedEventHandler', () => {
)
beforeEach(() => {
item = {
uuid: '1-2-3',
content: 'test',
} as jest.Mocked<Item>
item = 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()
itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
itemRepository.findByUuid = jest.fn().mockReturnValue(item)

View file

@ -1,6 +1,5 @@
import { ContentType } from '@standardnotes/common'
import { IntegrityPayload } from '@standardnotes/responses'
export type ExtendedIntegrityPayload = IntegrityPayload & {
content_type: ContentType
content_type: string | null
}

View file

@ -1,119 +1,22 @@
import { ContentType } from '@standardnotes/common'
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'
import { Entity, Result, UniqueEntityId } from '@standardnotes/domain-core'
@Entity({ name: 'items' })
@Index('index_items_on_user_uuid_and_content_type', ['userUuid', 'contentType'])
@Index('user_uuid_and_updated_at_timestamp_and_created_at_timestamp', [
'userUuid',
'updatedAtTimestamp',
'createdAtTimestamp',
])
@Index('user_uuid_and_deleted', ['userUuid', 'deleted'])
export class Item {
@PrimaryGeneratedColumn('uuid')
declare uuid: string
import { ItemProps } from './ItemProps'
@Column({
type: 'varchar',
name: 'duplicate_of',
length: 36,
nullable: true,
})
declare duplicateOf: string | null
export class Item extends Entity<ItemProps> {
get id(): UniqueEntityId {
return this._id
}
@Column({
type: 'varchar',
name: 'items_key_id',
length: 255,
nullable: true,
})
declare itemsKeyId: string | null
private constructor(props: ItemProps, id?: UniqueEntityId) {
super(props, id)
}
@Column({
type: 'text',
nullable: true,
})
declare content: string | null
static create(props: ItemProps, id?: UniqueEntityId): Result<Item> {
if (!props.contentSize) {
const contentSize = Buffer.byteLength(JSON.stringify(props))
props.contentSize = contentSize
}
@Column({
name: 'content_type',
type: 'varchar',
length: 255,
nullable: true,
})
@Index('index_items_on_content_type')
declare contentType: ContentType | null
@Column({
name: 'content_size',
type: 'int',
nullable: true,
})
declare contentSize: number | null
@Column({
name: 'enc_item_key',
type: 'text',
nullable: true,
})
declare encItemKey: string | null
@Column({
name: 'auth_hash',
type: 'varchar',
length: 255,
nullable: true,
})
declare authHash: string | null
@Column({
name: 'user_uuid',
length: 36,
})
@Index('index_items_on_user_uuid')
declare userUuid: string
@Column({
type: 'tinyint',
precision: 1,
nullable: true,
default: 0,
})
@Index('index_items_on_deleted')
declare deleted: boolean
@Column({
name: 'created_at',
type: 'datetime',
precision: 6,
})
declare createdAt: Date
@Column({
name: 'updated_at',
type: 'datetime',
precision: 6,
})
declare updatedAt: Date
@Column({
name: 'created_at_timestamp',
type: 'bigint',
})
declare createdAtTimestamp: number
@Column({
name: 'updated_at_timestamp',
type: 'bigint',
})
@Index('updated_at_timestamp')
declare updatedAtTimestamp: number
@Column({
name: 'updated_with_session',
type: 'varchar',
length: 36,
nullable: true,
})
declare updatedWithSession: string | null
return Result.ok<Item>(new Item(props, id))
}
}

View file

@ -1,198 +0,0 @@
import 'reflect-metadata'
import { Timer, TimerInterface } from '@standardnotes/time'
import { ContentType } from '@standardnotes/common'
import { ItemFactory } from './ItemFactory'
import { ItemHash } from './ItemHash'
import { ProjectorInterface } from '../../Projection/ProjectorInterface'
import { ItemProjection } from '../../Projection/ItemProjection'
import { Item } from './Item'
describe('ItemFactory', () => {
let timer: TimerInterface
let itemProjector: ProjectorInterface<Item, ItemProjection>
let timeHelper: Timer
const createFactory = () => new ItemFactory(timer, itemProjector)
beforeEach(() => {
timeHelper = new Timer()
timer = {} as jest.Mocked<TimerInterface>
timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(1616164633241568)
timer.convertMicrosecondsToDate = jest
.fn()
.mockImplementation((microseconds: number) => timeHelper.convertMicrosecondsToDate(microseconds))
timer.convertStringDateToMicroseconds = jest
.fn()
.mockImplementation((date: string) => timeHelper.convertStringDateToMicroseconds(date))
timer.convertStringDateToDate = jest
.fn()
.mockImplementation((date: string) => timeHelper.convertStringDateToDate(date))
itemProjector = {} as jest.Mocked<ProjectorInterface<Item, ItemProjection>>
itemProjector.projectFull = jest.fn().mockReturnValue({
uuid: '1-2-3',
items_key_id: 'foobar',
duplicate_of: null,
enc_item_key: 'foobar',
content: 'foobar',
content_type: ContentType.Note,
auth_hash: 'foobar',
deleted: false,
created_at: '2022-09-01 10:00:00',
created_at_timestamp: 123123123123123,
updated_at: '2022-09-01 10:00:00',
updated_at_timestamp: 123123123123123,
updated_with_session: '2-4-5',
})
})
it('should create an item based on item hash', () => {
const itemHash = {
uuid: '1-2-3',
} as jest.Mocked<ItemHash>
const item = createFactory().create({ userUuid: 'a-b-c', itemHash, sessionUuid: '1-2-3' })
expect(item).toEqual({
createdAtTimestamp: 1616164633241568,
createdAt: expect.any(Date),
updatedWithSession: '1-2-3',
updatedAt: expect.any(Date),
updatedAtTimestamp: 1616164633241568,
userUuid: 'a-b-c',
uuid: '1-2-3',
contentSize: 341,
})
})
it('should create a stub item based on item hash with update_at date and timestamps overwritten', () => {
const itemHash = {
uuid: '1-2-3',
updated_at: '2021-03-25T09:37:37.943Z',
} as jest.Mocked<ItemHash>
const item = createFactory().createStub({ userUuid: 'a-b-c', itemHash, sessionUuid: '1-2-3' })
expect(item).toEqual({
createdAtTimestamp: 1616164633241568,
createdAt: expect.any(Date),
updatedWithSession: '1-2-3',
updatedAt: new Date('2021-03-25T09:37:37.943Z'),
updatedAtTimestamp: 1616665057943000,
userUuid: 'a-b-c',
uuid: '1-2-3',
content: null,
contentSize: 341,
})
})
it('should create a stub item based on item hash with update_at_timestamp date and timestamps overwritten', () => {
const itemHash = {
uuid: '1-2-3',
updated_at_timestamp: 1616164633241568,
content: 'foobar',
} as jest.Mocked<ItemHash>
const item = createFactory().createStub({ userUuid: 'a-b-c', itemHash, sessionUuid: '1-2-3' })
expect(item).toEqual({
createdAtTimestamp: 1616164633241568,
createdAt: expect.any(Date),
updatedWithSession: '1-2-3',
updatedAt: new Date('2021-03-19T14:37:13.241Z'),
updatedAtTimestamp: 1616164633241568,
userUuid: 'a-b-c',
uuid: '1-2-3',
content: 'foobar',
contentSize: 341,
})
})
it('should create a stub item based on item hash without updated timestamps', () => {
const itemHash = {
uuid: '1-2-3',
} as jest.Mocked<ItemHash>
const item = createFactory().createStub({ userUuid: 'a-b-c', itemHash, sessionUuid: '1-2-3' })
expect(item).toEqual({
createdAtTimestamp: 1616164633241568,
createdAt: expect.any(Date),
updatedWithSession: '1-2-3',
updatedAt: expect.any(Date),
updatedAtTimestamp: 1616164633241568,
userUuid: 'a-b-c',
uuid: '1-2-3',
content: null,
contentSize: 341,
})
})
it('should create an item based on item hash with all fields filled', () => {
const itemHash = {
uuid: '1-2-3',
content: 'asdqwe1',
content_type: ContentType.Note,
duplicate_of: '222',
auth_hash: 'aaa',
deleted: true,
enc_item_key: 'qweqwe1',
items_key_id: 'asdasd1',
created_at: timeHelper.formatDate(new Date(1616164633241), 'YYYY-MM-DDTHH:mm:ss.SSS[Z]'),
updated_at: timeHelper.formatDate(new Date(1616164633242), 'YYYY-MM-DDTHH:mm:ss.SSS[Z]'),
} as jest.Mocked<ItemHash>
const item = createFactory().create({ userUuid: 'a-b-c', itemHash, sessionUuid: '1-2-3' })
expect(item).toEqual({
content: 'asdqwe1',
contentSize: 341,
contentType: 'Note',
createdAt: expect.any(Date),
updatedWithSession: '1-2-3',
createdAtTimestamp: 1616164633241000,
encItemKey: 'qweqwe1',
itemsKeyId: 'asdasd1',
authHash: 'aaa',
deleted: true,
duplicateOf: '222',
updatedAt: expect.any(Date),
updatedAtTimestamp: 1616164633241568,
userUuid: 'a-b-c',
uuid: '1-2-3',
})
})
it('should create an item based on item hash with created at timestamp', () => {
const itemHash = {
uuid: '1-2-3',
content: 'asdqwe1',
content_type: ContentType.Note,
duplicate_of: null,
enc_item_key: 'qweqwe1',
items_key_id: 'asdasd1',
created_at_timestamp: 1616164633241312,
updated_at: timeHelper.formatDate(new Date(1616164633242), 'YYYY-MM-DDTHH:mm:ss.SSS[Z]'),
} as jest.Mocked<ItemHash>
const item = createFactory().create({ userUuid: 'a-b-c', itemHash, sessionUuid: '1-2-3' })
expect(item).toEqual({
content: 'asdqwe1',
contentSize: 341,
contentType: 'Note',
createdAt: expect.any(Date),
updatedWithSession: '1-2-3',
createdAtTimestamp: 1616164633241312,
encItemKey: 'qweqwe1',
itemsKeyId: 'asdasd1',
updatedAt: expect.any(Date),
updatedAtTimestamp: 1616164633241568,
userUuid: 'a-b-c',
uuid: '1-2-3',
})
})
})

View file

@ -1,79 +0,0 @@
import { TimerInterface } from '@standardnotes/time'
import { ItemProjection } from '../../Projection/ItemProjection'
import { ProjectorInterface } from '../../Projection/ProjectorInterface'
import { Item } from './Item'
import { ItemFactoryInterface } from './ItemFactoryInterface'
import { ItemHash } from './ItemHash'
export class ItemFactory implements ItemFactoryInterface {
constructor(private timer: TimerInterface, private itemProjector: ProjectorInterface<Item, ItemProjection>) {}
createStub(dto: { userUuid: string; itemHash: ItemHash; sessionUuid: string | null }): Item {
const item = this.create(dto)
if (dto.itemHash.content === undefined) {
item.content = null
}
if (dto.itemHash.updated_at_timestamp) {
item.updatedAtTimestamp = dto.itemHash.updated_at_timestamp
item.updatedAt = this.timer.convertMicrosecondsToDate(dto.itemHash.updated_at_timestamp)
} else if (dto.itemHash.updated_at) {
item.updatedAtTimestamp = this.timer.convertStringDateToMicroseconds(dto.itemHash.updated_at)
item.updatedAt = this.timer.convertStringDateToDate(dto.itemHash.updated_at)
}
return item
}
create(dto: { userUuid: string; itemHash: ItemHash; sessionUuid: string | null }): Item {
const newItem = new Item()
newItem.uuid = dto.itemHash.uuid
newItem.updatedWithSession = dto.sessionUuid
newItem.contentSize = 0
if (dto.itemHash.content) {
newItem.content = dto.itemHash.content
}
newItem.userUuid = dto.userUuid
if (dto.itemHash.content_type) {
newItem.contentType = dto.itemHash.content_type
}
if (dto.itemHash.enc_item_key) {
newItem.encItemKey = dto.itemHash.enc_item_key
}
if (dto.itemHash.items_key_id) {
newItem.itemsKeyId = dto.itemHash.items_key_id
}
if (dto.itemHash.duplicate_of) {
newItem.duplicateOf = dto.itemHash.duplicate_of
}
if (dto.itemHash.deleted !== undefined) {
newItem.deleted = dto.itemHash.deleted
}
if (dto.itemHash.auth_hash) {
newItem.authHash = dto.itemHash.auth_hash
}
const now = this.timer.getTimestampInMicroseconds()
const nowDate = this.timer.convertMicrosecondsToDate(now)
newItem.updatedAtTimestamp = now
newItem.updatedAt = nowDate
newItem.createdAtTimestamp = now
newItem.createdAt = nowDate
if (dto.itemHash.created_at_timestamp) {
newItem.createdAtTimestamp = dto.itemHash.created_at_timestamp
newItem.createdAt = this.timer.convertMicrosecondsToDate(dto.itemHash.created_at_timestamp)
} else if (dto.itemHash.created_at) {
newItem.createdAtTimestamp = this.timer.convertStringDateToMicroseconds(dto.itemHash.created_at)
newItem.createdAt = this.timer.convertStringDateToDate(dto.itemHash.created_at)
}
newItem.contentSize = Buffer.byteLength(JSON.stringify(this.itemProjector.projectFull(newItem)))
return newItem
}
}

View file

@ -1,7 +0,0 @@
import { Item } from './Item'
import { ItemHash } from './ItemHash'
export interface ItemFactoryInterface {
create(dto: { userUuid: string; itemHash: ItemHash; sessionUuid: string | null }): Item
createStub(dto: { userUuid: string; itemHash: ItemHash; sessionUuid: string | null }): Item
}

View file

@ -1,9 +1,7 @@
import { ContentType } from '@standardnotes/common'
export type ItemHash = {
uuid: string
content?: string
content_type: ContentType
content_type: string | null
deleted?: boolean
duplicate_of?: string | null
auth_hash?: string

View file

@ -0,0 +1,16 @@
import { ContentType, Dates, Timestamps, Uuid } from '@standardnotes/domain-core'
export interface ItemProps {
duplicateOf: Uuid | null
itemsKeyId: string | null
content: string | null
contentType: ContentType
encItemKey: string | null
authHash: string | null
userUuid: Uuid
deleted: boolean
updatedWithSession: Uuid | null
dates: Dates
timestamps: Timestamps
contentSize?: number
}

View file

@ -16,8 +16,8 @@ export interface ItemRepositoryInterface {
findItemsForComputingIntegrityPayloads(userUuid: string): Promise<ExtendedIntegrityPayload[]>
findByUuidAndUserUuid(uuid: string, userUuid: string): Promise<Item | null>
findByUuid(uuid: string): Promise<Item | null>
remove(item: Item): Promise<Item>
save(item: Item): Promise<Item>
remove(item: Item): Promise<void>
save(item: Item): Promise<void>
markItemsAsDeleted(itemUuids: Array<string>, updatedAtTimestamp: number): Promise<void>
updateContentSize(itemUuid: string, contentSize: number): Promise<void>
}

View file

@ -1,94 +1,101 @@
import 'reflect-metadata'
import { ContentType } from '@standardnotes/common'
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 { DomainEventPublisherInterface } from '@standardnotes/domain-events'
import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface'
import { Logger } from 'winston'
import { Timer, TimerInterface } from '@standardnotes/time'
import { ItemSaveValidatorInterface } from './SaveValidator/ItemSaveValidatorInterface'
import { ItemFactoryInterface } from './ItemFactoryInterface'
import { ItemConflict } from './ItemConflict'
import { ItemTransferCalculatorInterface } from './ItemTransferCalculatorInterface'
import { ProjectorInterface } from '../../Projection/ProjectorInterface'
import { ItemProjection } from '../../Projection/ItemProjection'
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
let domainEventPublisher: DomainEventPublisherInterface
let domainEventFactory: DomainEventFactoryInterface
const revisionFrequency = 300
const contentSizeTransferLimit = 100
let timer: TimerInterface
let item1: Item
let item2: Item
let itemHash1: ItemHash
let itemHash2: ItemHash
let emptyHash: ItemHash
let syncToken: string
let logger: Logger
let itemSaveValidator: ItemSaveValidatorInterface
let newItem: Item
let itemFactory: ItemFactoryInterface
let timeHelper: Timer
let itemTransferCalculator: ItemTransferCalculatorInterface
let itemProjector: ProjectorInterface<Item, ItemProjection>
let saveNewItemUseCase: SaveNewItem
let updateExistingItemUseCase: UpdateExistingItem
const maxItemsSyncLimit = 300
const createService = () =>
new ItemService(
itemSaveValidator,
itemFactory,
itemRepository,
domainEventPublisher,
domainEventFactory,
revisionFrequency,
contentSizeTransferLimit,
itemTransferCalculator,
timer,
itemProjector,
maxItemsSyncLimit,
saveNewItemUseCase,
updateExistingItemUseCase,
logger,
)
beforeEach(() => {
timeHelper = new Timer()
item1 = {
uuid: '1-2-3',
userUuid: '1-2-3',
createdAt: new Date(1616164633241311),
createdAtTimestamp: 1616164633241311,
updatedAt: new Date(1616164633241311),
updatedAtTimestamp: 1616164633241311,
} as jest.Mocked<Item>
item2 = {
uuid: '2-3-4',
userUuid: '1-2-3',
createdAt: new Date(1616164633241312),
createdAtTimestamp: 1616164633241312,
updatedAt: new Date(1616164633241312),
updatedAtTimestamp: 1616164633241312,
} as jest.Mocked<Item>
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.Note,
content_type: ContentType.TYPES.Note,
duplicate_of: null,
enc_item_key: 'qweqwe1',
items_key_id: 'asdasd1',
created_at: timeHelper.formatDate(
timeHelper.convertMicrosecondsToDate(item1.createdAtTimestamp),
timeHelper.convertMicrosecondsToDate(item1.props.timestamps.createdAt),
'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
),
updated_at: timeHelper.formatDate(
new Date(timeHelper.convertMicrosecondsToMilliseconds(item1.updatedAtTimestamp) + 1),
new Date(timeHelper.convertMicrosecondsToMilliseconds(item1.props.timestamps.updatedAt) + 1),
'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
),
} as jest.Mocked<ItemHash>
@ -96,31 +103,28 @@ describe('ItemService', () => {
itemHash2 = {
uuid: '2-3-4',
content: 'asdqwe2',
content_type: ContentType.Note,
content_type: ContentType.TYPES.Note,
duplicate_of: null,
enc_item_key: 'qweqwe2',
items_key_id: 'asdasd2',
created_at: timeHelper.formatDate(
timeHelper.convertMicrosecondsToDate(item2.createdAtTimestamp),
timeHelper.convertMicrosecondsToDate(item2.props.timestamps.createdAt),
'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
),
updated_at: timeHelper.formatDate(
new Date(timeHelper.convertMicrosecondsToMilliseconds(item2.updatedAtTimestamp) + 1),
new Date(timeHelper.convertMicrosecondsToMilliseconds(item2.props.timestamps.updatedAt) + 1),
'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
),
} as jest.Mocked<ItemHash>
emptyHash = {
uuid: '2-3-4',
} as jest.Mocked<ItemHash>
itemTransferCalculator = {} as jest.Mocked<ItemTransferCalculatorInterface>
itemTransferCalculator.computeItemUuidsToFetch = jest.fn().mockReturnValue([item1.uuid, item2.uuid])
itemTransferCalculator.computeItemUuidsToFetch = jest
.fn()
.mockReturnValue([item1.id.toString(), item2.id.toString()])
itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
itemRepository.findAll = jest.fn().mockReturnValue([item1, item2])
itemRepository.countAll = jest.fn().mockReturnValue(2)
itemRepository.save = jest.fn().mockImplementation((item: Item) => item)
timer = {} as jest.Mocked<TimerInterface>
timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(1616164633241568)
@ -136,13 +140,6 @@ describe('ItemService', () => {
.fn()
.mockImplementation((microseconds: number) => timeHelper.convertMicrosecondsToDate(microseconds))
domainEventPublisher = {} as jest.Mocked<DomainEventPublisherInterface>
domainEventPublisher.publish = jest.fn()
domainEventFactory = {} as jest.Mocked<DomainEventFactoryInterface>
domainEventFactory.createDuplicateItemSyncedEvent = jest.fn()
domainEventFactory.createItemRevisionCreationRequested = jest.fn()
logger = {} as jest.Mocked<Logger>
logger.error = jest.fn()
logger.warn = jest.fn()
@ -152,31 +149,28 @@ describe('ItemService', () => {
itemSaveValidator = {} as jest.Mocked<ItemSaveValidatorInterface>
itemSaveValidator.validate = jest.fn().mockReturnValue({ passed: true })
newItem = {
contentType: ContentType.Note,
} as jest.Mocked<Item>
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()
itemFactory = {} as jest.Mocked<ItemFactoryInterface>
itemFactory.create = jest.fn().mockReturnValue(newItem)
itemFactory.createStub = jest.fn().mockReturnValue(newItem)
saveNewItemUseCase = {} as jest.Mocked<SaveNewItem>
saveNewItemUseCase.execute = jest.fn().mockReturnValue(Result.ok(newItem))
itemProjector = {} as jest.Mocked<ProjectorInterface<Item, ItemProjection>>
itemProjector.projectFull = jest.fn().mockReturnValue({
uuid: '1-2-3',
items_key_id: 'foobar',
duplicate_of: null,
enc_item_key: 'foobar',
content:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Sed viverra tellus in hac habitasse. Tortor posuere ac ut consequat semper. Ut diam quam nulla porttitor. Sapien pellentesque habitant morbi tristique senectus et netus et malesuada. Dapibus ultrices in iaculis nunc. Pellentesque habitant morbi tristique senectus et netus et malesuada fames. Faucibus et molestie ac feugiat sed lectus vestibulum mattis. Eu consequat ac felis donec. Eget velit aliquet sagittis id. Nullam eget felis eget nunc. Turpis in eu mi bibendum neque egestas congue.',
content_type: ContentType.Note,
auth_hash: 'foobar',
deleted: false,
created_at: '2022-09-01 10:00:00',
created_at_timestamp: 123123123123123,
updated_at: '2022-09-01 10:00:00',
updated_at_timestamp: 123123123123123,
updated_with_session: '2-4-5',
})
updateExistingItemUseCase = {} as jest.Mocked<UpdateExistingItem>
updateExistingItemUseCase.execute = jest.fn().mockReturnValue(Result.ok(item1))
})
it('should retrieve all items for a user from last sync with sync token version 1', async () => {
@ -186,7 +180,7 @@ describe('ItemService', () => {
await createService().getItems({
userUuid: '1-2-3',
syncToken,
contentType: ContentType.Note,
contentType: ContentType.TYPES.Note,
}),
).toEqual({
items: [item1, item2],
@ -202,7 +196,7 @@ describe('ItemService', () => {
limit: 150,
})
expect(itemRepository.findAll).toHaveBeenCalledWith({
uuids: ['1-2-3', '2-3-4'],
uuids: ['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'],
sortOrder: 'ASC',
sortBy: 'updated_at_timestamp',
})
@ -213,7 +207,7 @@ describe('ItemService', () => {
await createService().getItems({
userUuid: '1-2-3',
syncToken,
contentType: ContentType.Note,
contentType: ContentType.TYPES.Note,
}),
).toEqual({
items: [item1, item2],
@ -229,7 +223,7 @@ describe('ItemService', () => {
limit: 150,
})
expect(itemRepository.findAll).toHaveBeenCalledWith({
uuids: ['1-2-3', '2-3-4'],
uuids: ['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'],
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',
})
@ -240,7 +234,7 @@ describe('ItemService', () => {
await createService().getItems({
userUuid: '1-2-3',
syncToken,
contentType: ContentType.Note,
contentType: ContentType.TYPES.Note,
limit: 1000,
}),
).toEqual({
@ -257,7 +251,7 @@ describe('ItemService', () => {
limit: 300,
})
expect(itemRepository.findAll).toHaveBeenCalledWith({
uuids: ['1-2-3', '2-3-4'],
uuids: ['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'],
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',
})
@ -270,7 +264,7 @@ describe('ItemService', () => {
await createService().getItems({
userUuid: '1-2-3',
syncToken,
contentType: ContentType.Note,
contentType: ContentType.TYPES.Note,
}),
).toEqual({
items: [],
@ -295,7 +289,7 @@ describe('ItemService', () => {
userUuid: '1-2-3',
syncToken,
limit: 1,
contentType: ContentType.Note,
contentType: ContentType.TYPES.Note,
})
expect(itemsResponse).toEqual({
@ -315,7 +309,7 @@ describe('ItemService', () => {
limit: 1,
})
expect(itemRepository.findAll).toHaveBeenCalledWith({
uuids: ['1-2-3', '2-3-4'],
uuids: ['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'],
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',
})
@ -329,7 +323,7 @@ describe('ItemService', () => {
userUuid: '1-2-3',
syncToken,
cursorToken,
contentType: ContentType.Note,
contentType: ContentType.TYPES.Note,
}),
).toEqual({
items: [item1, item2],
@ -345,7 +339,7 @@ describe('ItemService', () => {
limit: 150,
})
expect(itemRepository.findAll).toHaveBeenCalledWith({
uuids: ['1-2-3', '2-3-4'],
uuids: ['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'],
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',
})
@ -355,7 +349,7 @@ describe('ItemService', () => {
expect(
await createService().getItems({
userUuid: '1-2-3',
contentType: ContentType.Note,
contentType: ContentType.TYPES.Note,
}),
).toEqual({
items: [item1, item2],
@ -371,7 +365,7 @@ describe('ItemService', () => {
limit: 150,
})
expect(itemRepository.findAll).toHaveBeenCalledWith({
uuids: ['1-2-3', '2-3-4'],
uuids: ['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'],
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',
})
@ -381,7 +375,7 @@ describe('ItemService', () => {
await createService().getItems({
userUuid: '1-2-3',
syncToken,
contentType: ContentType.Note,
contentType: ContentType.TYPES.Note,
})
expect(itemRepository.countAll).toHaveBeenCalledWith({
@ -394,7 +388,7 @@ describe('ItemService', () => {
limit: 150,
})
expect(itemRepository.findAll).toHaveBeenCalledWith({
uuids: ['1-2-3', '2-3-4'],
uuids: ['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'],
sortOrder: 'ASC',
sortBy: 'updated_at_timestamp',
})
@ -405,7 +399,7 @@ describe('ItemService', () => {
userUuid: '1-2-3',
syncToken,
limit: 0,
contentType: ContentType.Note,
contentType: ContentType.TYPES.Note,
})
expect(itemRepository.countAll).toHaveBeenCalledWith({
@ -418,7 +412,7 @@ describe('ItemService', () => {
limit: 150,
})
expect(itemRepository.findAll).toHaveBeenCalledWith({
uuids: ['1-2-3', '2-3-4'],
uuids: ['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'],
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',
})
@ -432,7 +426,7 @@ describe('ItemService', () => {
userUuid: '1-2-3',
syncToken: '2:',
limit: 0,
contentType: ContentType.Note,
contentType: ContentType.TYPES.Note,
})
} catch (e) {
error = e
@ -449,7 +443,7 @@ describe('ItemService', () => {
userUuid: '1-2-3',
syncToken: '1234567890',
limit: 0,
contentType: ContentType.Note,
contentType: ContentType.TYPES.Note,
})
} catch (e) {
error = e
@ -459,12 +453,38 @@ describe('ItemService', () => {
})
it('should front load keys items to top of the collection for better client performance', async () => {
const item3 = {
uuid: '1-2-3',
} as jest.Mocked<Item>
const item4 = {
uuid: '4-5-6',
} as jest.Mocked<Item>
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])
@ -485,11 +505,10 @@ describe('ItemService', () => {
expect(result).toEqual({
conflicts: [],
savedItems: [newItem],
syncToken: 'MjpOYU4=',
syncToken: 'MjoxNjE2MTY0NjMzLjI0MTMxNA==',
})
expect(domainEventFactory.createItemRevisionCreationRequested).toHaveBeenCalledTimes(1)
expect(domainEventPublisher.publish).toHaveBeenCalledTimes(1)
expect(saveNewItemUseCase.execute).toHaveBeenCalled()
})
it('should not save new items in read only access mode', async () => {
@ -513,16 +532,29 @@ describe('ItemService', () => {
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 = {
updatedAtTimestamp: 1616164633241570,
duplicateOf: '1-2-3',
contentType: ContentType.Note,
} as jest.Mocked<Item>
itemFactory.create = jest.fn().mockReturnValueOnce(duplicateItem)
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],
@ -537,10 +569,6 @@ describe('ItemService', () => {
savedItems: [duplicateItem],
syncToken: 'MjoxNjE2MTY0NjMzLjI0MTU3MQ==',
})
expect(domainEventFactory.createItemRevisionCreationRequested).toHaveBeenCalledTimes(1)
expect(domainEventPublisher.publish).toHaveBeenCalledTimes(2)
expect(domainEventFactory.createDuplicateItemSyncedEvent).toHaveBeenCalledTimes(1)
})
it('should skip items that are conflicting on validation', async () => {
@ -568,7 +596,7 @@ describe('ItemService', () => {
it('should mark items as saved that are skipped on validation', async () => {
itemRepository.findByUuid = jest.fn().mockReturnValue(null)
const skipped = {} as jest.Mocked<Item>
const skipped = item1
const validationResult = { passed: false, skipped }
itemSaveValidator.validate = jest.fn().mockReturnValue(validationResult)
@ -583,7 +611,7 @@ describe('ItemService', () => {
expect(result).toEqual({
conflicts: [],
savedItems: [skipped],
syncToken: 'MjpOYU4=',
syncToken: 'MjoxNjE2MTY0NjMzLjI0MTMxMg==',
})
})
@ -593,7 +621,7 @@ describe('ItemService', () => {
const itemHash3 = {
uuid: '3-4-5',
content: 'asdqwe3',
content_type: ContentType.Note,
content_type: ContentType.TYPES.Note,
duplicate_of: null,
enc_item_key: 'qweqwe3',
items_key_id: 'asdasd3',
@ -607,11 +635,41 @@ describe('ItemService', () => {
const item3Timestamp = 1616164633241569
timer.getTimestampInMicroseconds = jest.fn().mockReturnValueOnce(saveProcedureStartTimestamp)
itemFactory.create = jest
saveNewItemUseCase.execute = jest
.fn()
.mockReturnValueOnce({ updatedAtTimestamp: item1Timestamp, duplicateOf: null } as jest.Mocked<Item>)
.mockReturnValueOnce({ updatedAtTimestamp: item2Timestamp, duplicateOf: null } as jest.Mocked<Item>)
.mockReturnValueOnce({ updatedAtTimestamp: item3Timestamp, duplicateOf: null } as jest.Mocked<Item>)
.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],
@ -638,66 +696,14 @@ describe('ItemService', () => {
expect(result).toEqual({
conflicts: [],
savedItems: [
{
content: 'asdqwe1',
contentSize: 950,
contentType: 'Note',
createdAtTimestamp: expect.any(Number),
createdAt: expect.any(Date),
encItemKey: 'qweqwe1',
itemsKeyId: 'asdasd1',
userUuid: '1-2-3',
updatedAtTimestamp: expect.any(Number),
updatedAt: expect.any(Date),
updatedWithSession: '2-3-4',
uuid: '1-2-3',
},
],
syncToken: 'MjoxNjE2MTY0NjMzLjI0MTU2OQ==',
savedItems: [item1],
syncToken: 'MjoxNjE2MTY0NjMzLjI0MTMxMg==',
})
})
it('should update existing items from legacy clients', async () => {
itemRepository.findByUuid = jest.fn().mockReturnValue(item1)
delete itemHash1.updated_at
delete itemHash1.updated_at_timestamp
const result = await createService().saveItems({
itemHashes: [itemHash1],
userUuid: '1-2-3',
apiVersion: ApiVersion.v20161215,
readOnlyAccess: false,
sessionUuid: '2-3-4',
})
expect(result).toEqual({
conflicts: [],
savedItems: [
{
content: 'asdqwe1',
contentSize: 950,
contentType: 'Note',
createdAtTimestamp: expect.any(Number),
createdAt: expect.any(Date),
encItemKey: 'qweqwe1',
itemsKeyId: 'asdasd1',
userUuid: '1-2-3',
updatedAtTimestamp: expect.any(Number),
updatedAt: expect.any(Date),
updatedWithSession: '2-3-4',
uuid: '1-2-3',
},
],
syncToken: 'MjoxNjE2MTY0NjMzLjI0MTU2OQ==',
})
})
it('should update existing items with created_at_timestamp', async () => {
itemHash1.created_at_timestamp = 123
itemHash1.updated_at_timestamp = item1.updatedAtTimestamp
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],
@ -708,240 +714,49 @@ describe('ItemService', () => {
})
expect(result).toEqual({
conflicts: [],
savedItems: [
conflicts: [
{
content: 'asdqwe1',
contentSize: 950,
contentType: 'Note',
createdAtTimestamp: 123,
createdAt: expect.any(Date),
encItemKey: 'qweqwe1',
itemsKeyId: 'asdasd1',
userUuid: '1-2-3',
updatedAtTimestamp: expect.any(Number),
updatedAt: expect.any(Date),
updatedWithSession: '2-3-4',
uuid: '1-2-3',
type: 'uuid_conflict',
unsavedItem: itemHash1,
},
],
savedItems: [],
syncToken: 'MjoxNjE2MTY0NjMzLjI0MTU2OQ==',
})
})
it('should update existing empty hashes', async () => {
itemRepository.findByUuid = jest.fn().mockReturnValue(item2)
emptyHash.updated_at = timeHelper.formatDate(
new Date(timeHelper.convertMicrosecondsToMilliseconds(item2.updatedAtTimestamp) + 1),
'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
)
const result = await createService().saveItems({
itemHashes: [emptyHash],
userUuid: '1-2-3',
apiVersion: ApiVersion.v20200115,
readOnlyAccess: false,
sessionUuid: '2-3-4',
})
expect(result).toEqual({
conflicts: [],
savedItems: [
{
contentSize: 950,
createdAtTimestamp: expect.any(Number),
createdAt: expect.any(Date),
userUuid: '1-2-3',
updatedAtTimestamp: expect.any(Number),
updatedAt: expect.any(Date),
updatedWithSession: '2-3-4',
uuid: '2-3-4',
},
],
syncToken: 'MjoxNjE2MTY0NjMzLjI0MTU2OQ==',
})
})
it('should create a revision for existing item if revisions frequency is matched', async () => {
timer.convertMicrosecondsToSeconds = 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: [
{
content: 'asdqwe1',
contentSize: 950,
contentType: 'Note',
createdAtTimestamp: expect.any(Number),
createdAt: expect.any(Date),
encItemKey: 'qweqwe1',
itemsKeyId: 'asdasd1',
userUuid: '1-2-3',
updatedAtTimestamp: expect.any(Number),
updatedAt: expect.any(Date),
updatedWithSession: '2-3-4',
uuid: '1-2-3',
},
],
syncToken: 'MjoxNjE2MTY0NjMzLjI0MTU2OQ==',
})
})
it('should update existing items with empty user-agent', 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: [
{
content: 'asdqwe1',
contentSize: 950,
contentType: 'Note',
createdAtTimestamp: expect.any(Number),
createdAt: expect.any(Date),
encItemKey: 'qweqwe1',
itemsKeyId: 'asdasd1',
userUuid: '1-2-3',
updatedAtTimestamp: expect.any(Number),
updatedAt: expect.any(Date),
updatedWithSession: '2-3-4',
uuid: '1-2-3',
},
],
syncToken: 'MjoxNjE2MTY0NjMzLjI0MTU2OQ==',
})
})
it('should update existing items with auth hash', async () => {
itemRepository.findByUuid = jest.fn().mockReturnValue(item1)
itemHash1.auth_hash = 'test'
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: [
{
content: 'asdqwe1',
contentSize: 950,
contentType: 'Note',
createdAtTimestamp: expect.any(Number),
createdAt: expect.any(Date),
encItemKey: 'qweqwe1',
itemsKeyId: 'asdasd1',
authHash: 'test',
userUuid: '1-2-3',
updatedAtTimestamp: expect.any(Number),
updatedAt: expect.any(Date),
updatedWithSession: '2-3-4',
uuid: '1-2-3',
},
],
syncToken: 'MjoxNjE2MTY0NjMzLjI0MTU2OQ==',
})
})
it('should mark existing item as deleted', async () => {
itemRepository.findByUuid = jest.fn().mockReturnValue(item1)
itemHash1.deleted = true
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: [
{
content: null,
contentSize: 0,
authHash: null,
contentType: 'Note',
createdAtTimestamp: expect.any(Number),
createdAt: expect.any(Date),
encItemKey: null,
deleted: true,
itemsKeyId: null,
userUuid: '1-2-3',
updatedAtTimestamp: expect.any(Number),
updatedAt: expect.any(Date),
updatedWithSession: '2-3-4',
uuid: '1-2-3',
},
],
syncToken: 'MjoxNjE2MTY0NjMzLjI0MTU2OQ==',
})
})
it('should mark existing item as duplicate', async () => {
itemRepository.findByUuid = jest.fn().mockReturnValue(item1)
itemHash1.duplicate_of = '1-2-3'
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: [
{
content: 'asdqwe1',
contentSize: 950,
contentType: 'Note',
createdAtTimestamp: expect.any(Number),
createdAt: expect.any(Date),
encItemKey: 'qweqwe1',
duplicateOf: '1-2-3',
itemsKeyId: 'asdasd1',
userUuid: '1-2-3',
updatedAtTimestamp: expect.any(Number),
updatedAt: expect.any(Date),
updatedWithSession: '2-3-4',
uuid: '1-2-3',
},
],
syncToken: 'MjoxNjE2MTY0NjMzLjI0MTU2OQ==',
})
expect(domainEventPublisher.publish).toHaveBeenCalledTimes(2)
expect(domainEventFactory.createDuplicateItemSyncedEvent).toHaveBeenCalledTimes(1)
expect(domainEventFactory.createItemRevisionCreationRequested).toHaveBeenCalledTimes(1)
})
it('should skip saving conflicting items and mark them as sync conflicts when saving to database fails', async () => {
it('should skip saving conflicting items and mark them as sync conflicts when saving fails', async () => {
itemRepository.findByUuid = jest.fn().mockReturnValue(null)
itemRepository.save = jest.fn().mockImplementation(() => {
throw new Error('Something bad happened')
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({

View file

@ -1,15 +1,10 @@
import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
import { Time, TimerInterface } from '@standardnotes/time'
import { ContentType } from '@standardnotes/common'
import { Logger } from 'winston'
import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface'
import { GetItemsDTO } from './GetItemsDTO'
import { GetItemsResult } from './GetItemsResult'
import { Item } from './Item'
import { ItemConflict } from './ItemConflict'
import { ItemFactoryInterface } from './ItemFactoryInterface'
import { ItemHash } from './ItemHash'
import { ItemQuery } from './ItemQuery'
import { ItemRepositoryInterface } from './ItemRepositoryInterface'
import { ItemServiceInterface } from './ItemServiceInterface'
@ -18,8 +13,9 @@ import { SaveItemsResult } from './SaveItemsResult'
import { ItemSaveValidatorInterface } from './SaveValidator/ItemSaveValidatorInterface'
import { ConflictType } from '@standardnotes/responses'
import { ItemTransferCalculatorInterface } from './ItemTransferCalculatorInterface'
import { ProjectorInterface } from '../../Projection/ProjectorInterface'
import { ItemProjection } from '../../Projection/ItemProjection'
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
@ -27,16 +23,13 @@ export class ItemService implements ItemServiceInterface {
constructor(
private itemSaveValidator: ItemSaveValidatorInterface,
private itemFactory: ItemFactoryInterface,
private itemRepository: ItemRepositoryInterface,
private domainEventPublisher: DomainEventPublisherInterface,
private domainEventFactory: DomainEventFactoryInterface,
private revisionFrequency: number,
private contentSizeTransferLimit: number,
private itemTransferCalculator: ItemTransferCalculatorInterface,
private timer: TimerInterface,
private itemProjector: ProjectorInterface<Item, ItemProjection>,
private maxItemsSyncLimit: number,
private saveNewItem: SaveNewItem,
private updateExistingItem: UpdateExistingItem,
private logger: Logger,
) {}
@ -73,7 +66,7 @@ export class ItemService implements ItemServiceInterface {
let cursorToken = undefined
if (totalItemsCount > upperBoundLimit) {
const lastSyncTime = items[items.length - 1].updatedAtTimestamp / Time.MicrosecondsInASecond
const lastSyncTime = items[items.length - 1].props.timestamps.updatedAt / Time.MicrosecondsInASecond
cursorToken = Buffer.from(`${this.SYNC_TOKEN_VERSION}:${lastSyncTime}`, 'utf-8').toString('base64')
}
@ -118,15 +111,47 @@ export class ItemService implements ItemServiceInterface {
}
if (existingItem) {
const updatedItem = await this.updateExistingItem({
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 newItem = await this.saveNewItem({ userUuid: dto.userUuid, itemHash, sessionUuid: dto.sessionUuid })
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}`)
@ -153,15 +178,15 @@ export class ItemService implements ItemServiceInterface {
async frontLoadKeysItemsToTop(userUuid: string, retrievedItems: Array<Item>): Promise<Array<Item>> {
const itemsKeys = await this.itemRepository.findAll({
userUuid,
contentType: ContentType.ItemsKey,
contentType: ContentType.TYPES.ItemsKey,
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',
})
const retrievedItemsIds: Array<string> = retrievedItems.map((item: Item) => item.uuid)
const retrievedItemsIds: Array<string> = retrievedItems.map((item: Item) => item.id.toString())
itemsKeys.forEach((itemKey: Item) => {
if (retrievedItemsIds.indexOf(itemKey.uuid) === -1) {
if (retrievedItemsIds.indexOf(itemKey.id.toString()) === -1) {
retrievedItems.unshift(itemKey)
}
})
@ -172,9 +197,9 @@ export class ItemService implements ItemServiceInterface {
private calculateSyncToken(lastUpdatedTimestamp: number, savedItems: Array<Item>): string {
if (savedItems.length) {
const sortedItems = savedItems.sort((itemA: Item, itemB: Item) => {
return itemA.updatedAtTimestamp > itemB.updatedAtTimestamp ? 1 : -1
return itemA.props.timestamps.updatedAt > itemB.props.timestamps.updatedAt ? 1 : -1
})
lastUpdatedTimestamp = sortedItems[sortedItems.length - 1].updatedAtTimestamp
lastUpdatedTimestamp = sortedItems[sortedItems.length - 1].props.timestamps.updatedAt
}
const lastUpdatedTimestampWithMicrosecondPreventingSyncDoubles = lastUpdatedTimestamp + 1
@ -187,103 +212,6 @@ export class ItemService implements ItemServiceInterface {
).toString('base64')
}
private async updateExistingItem(dto: {
existingItem: Item
itemHash: ItemHash
sessionUuid: string | null
}): Promise<Item> {
dto.existingItem.updatedWithSession = dto.sessionUuid
dto.existingItem.contentSize = 0
if (dto.itemHash.content) {
dto.existingItem.content = dto.itemHash.content
}
if (dto.itemHash.content_type) {
dto.existingItem.contentType = dto.itemHash.content_type
}
if (dto.itemHash.deleted !== undefined) {
dto.existingItem.deleted = dto.itemHash.deleted
}
let wasMarkedAsDuplicate = false
if (dto.itemHash.duplicate_of) {
wasMarkedAsDuplicate = !dto.existingItem.duplicateOf
dto.existingItem.duplicateOf = dto.itemHash.duplicate_of
}
if (dto.itemHash.auth_hash) {
dto.existingItem.authHash = dto.itemHash.auth_hash
}
if (dto.itemHash.enc_item_key) {
dto.existingItem.encItemKey = dto.itemHash.enc_item_key
}
if (dto.itemHash.items_key_id) {
dto.existingItem.itemsKeyId = dto.itemHash.items_key_id
}
const updatedAt = this.timer.getTimestampInMicroseconds()
const secondsFromLastUpdate = this.timer.convertMicrosecondsToSeconds(
updatedAt - dto.existingItem.updatedAtTimestamp,
)
if (dto.itemHash.created_at_timestamp) {
dto.existingItem.createdAtTimestamp = dto.itemHash.created_at_timestamp
dto.existingItem.createdAt = this.timer.convertMicrosecondsToDate(dto.itemHash.created_at_timestamp)
} else if (dto.itemHash.created_at) {
dto.existingItem.createdAtTimestamp = this.timer.convertStringDateToMicroseconds(dto.itemHash.created_at)
dto.existingItem.createdAt = this.timer.convertStringDateToDate(dto.itemHash.created_at)
}
dto.existingItem.updatedAtTimestamp = updatedAt
dto.existingItem.updatedAt = this.timer.convertMicrosecondsToDate(updatedAt)
dto.existingItem.contentSize = Buffer.byteLength(JSON.stringify(this.itemProjector.projectFull(dto.existingItem)))
if (dto.itemHash.deleted === true) {
dto.existingItem.deleted = true
dto.existingItem.content = null
dto.existingItem.contentSize = 0
dto.existingItem.encItemKey = null
dto.existingItem.authHash = null
dto.existingItem.itemsKeyId = null
}
const savedItem = await this.itemRepository.save(dto.existingItem)
if (secondsFromLastUpdate >= this.revisionFrequency) {
if ([ContentType.Note, ContentType.File].includes(savedItem.contentType as ContentType)) {
await this.domainEventPublisher.publish(
this.domainEventFactory.createItemRevisionCreationRequested(savedItem.uuid, savedItem.userUuid),
)
}
}
if (wasMarkedAsDuplicate) {
await this.domainEventPublisher.publish(
this.domainEventFactory.createDuplicateItemSyncedEvent(savedItem.uuid, savedItem.userUuid),
)
}
return savedItem
}
private async saveNewItem(dto: { userUuid: string; itemHash: ItemHash; sessionUuid: string | null }): Promise<Item> {
const newItem = this.itemFactory.create(dto)
const savedItem = await this.itemRepository.save(newItem)
if ([ContentType.Note, ContentType.File].includes(savedItem.contentType as ContentType)) {
await this.domainEventPublisher.publish(
this.domainEventFactory.createItemRevisionCreationRequested(savedItem.uuid, savedItem.userUuid),
)
}
if (savedItem.duplicateOf) {
await this.domainEventPublisher.publish(
this.domainEventFactory.createDuplicateItemSyncedEvent(savedItem.uuid, savedItem.userUuid),
)
}
return savedItem
}
private getLastSyncTime(dto: GetItemsDTO): number | undefined {
let token = dto.syncToken
if (dto.cursorToken !== undefined && dto.cursorToken !== null) {

View file

@ -1,11 +1,10 @@
import 'reflect-metadata'
import { ContentType } from '@standardnotes/common'
import { ApiVersion } from '../../Api/ApiVersion'
import { Item } from '../Item'
import { ContentFilter } from './ContentFilter'
import { ContentType } from '@standardnotes/domain-core'
describe('ContentFilter', () => {
let existingItem: Item
@ -21,7 +20,7 @@ describe('ContentFilter', () => {
itemHash: {
uuid: '123e4567-e89b-12d3-a456-426655440000',
content: invalidContent as unknown as string,
content_type: ContentType.Note,
content_type: ContentType.TYPES.Note,
},
existingItem: null,
})
@ -32,7 +31,7 @@ describe('ContentFilter', () => {
unsavedItem: {
uuid: '123e4567-e89b-12d3-a456-426655440000',
content: invalidContent,
content_type: ContentType.Note,
content_type: ContentType.TYPES.Note,
},
type: 'content_error',
},
@ -50,7 +49,7 @@ describe('ContentFilter', () => {
itemHash: {
uuid: '123e4567-e89b-12d3-a456-426655440000',
content: validContent as unknown as string,
content_type: ContentType.Note,
content_type: ContentType.TYPES.Note,
},
existingItem,
})

View file

@ -1,5 +1,5 @@
import { ContentType } from '@standardnotes/common'
import 'reflect-metadata'
import { ApiVersion } from '../../Api/ApiVersion'
import { Item } from '../Item'
@ -27,7 +27,7 @@ describe('ContentTypeFilter', () => {
apiVersion: ApiVersion.v20200115,
itemHash: {
uuid: '123e4567-e89b-12d3-a456-426655440000',
content_type: invalidContentType as ContentType,
content_type: invalidContentType,
},
existingItem: null,
})
@ -54,7 +54,7 @@ describe('ContentTypeFilter', () => {
apiVersion: ApiVersion.v20200115,
itemHash: {
uuid: '123e4567-e89b-12d3-a456-426655440000',
content_type: validContentType as ContentType,
content_type: validContentType,
},
existingItem,
})

View file

@ -1,15 +1,14 @@
import { ContentType } from '@standardnotes/common'
import { ConflictType } from '@standardnotes/responses'
import { ContentType } from '@standardnotes/domain-core'
import { ItemSaveValidationDTO } from '../SaveValidator/ItemSaveValidationDTO'
import { ItemSaveRuleResult } from './ItemSaveRuleResult'
import { ItemSaveRuleInterface } from './ItemSaveRuleInterface'
import { ConflictType } from '@standardnotes/responses'
export class ContentTypeFilter implements ItemSaveRuleInterface {
async check(dto: ItemSaveValidationDTO): Promise<ItemSaveRuleResult> {
const validContentType = Object.values(ContentType).includes(dto.itemHash.content_type as ContentType)
if (!validContentType) {
const contentTypeOrError = ContentType.create(dto.itemHash.content_type)
if (contentTypeOrError.isFailed()) {
return {
passed: false,
conflict: {

View file

@ -1,28 +1,41 @@
import 'reflect-metadata'
import { ContentType } from '@standardnotes/common'
import { ApiVersion } from '../../Api/ApiVersion'
import { Item } from '../Item'
import { OwnershipFilter } from './OwnershipFilter'
import { Uuid, ContentType, Dates, Timestamps, UniqueEntityId } from '@standardnotes/domain-core'
describe('OwnershipFilter', () => {
let existingItem: Item
const createFilter = () => new OwnershipFilter()
beforeEach(() => {
existingItem = {} as jest.Mocked<Item>
existingItem.userUuid = '2-3-4'
existingItem = Item.create(
{
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
updatedWithSession: null,
content: 'foobar',
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()
})
it('should filter out items belonging to a different user', async () => {
const result = await createFilter().check({
userUuid: '1-2-3',
userUuid: '00000000-0000-0000-0000-000000000001',
apiVersion: ApiVersion.v20200115,
itemHash: {
uuid: '2-3-4',
content_type: ContentType.Note,
content_type: ContentType.TYPES.Note,
},
existingItem,
})
@ -32,7 +45,7 @@ describe('OwnershipFilter', () => {
conflict: {
unsavedItem: {
uuid: '2-3-4',
content_type: ContentType.Note,
content_type: ContentType.TYPES.Note,
},
type: 'uuid_conflict',
},
@ -40,14 +53,12 @@ describe('OwnershipFilter', () => {
})
it('should leave items belonging to the same user', async () => {
existingItem.userUuid = '1-2-3'
const result = await createFilter().check({
userUuid: '1-2-3',
userUuid: '00000000-0000-0000-0000-000000000000',
apiVersion: ApiVersion.v20200115,
itemHash: {
uuid: '2-3-4',
content_type: ContentType.Note,
content_type: ContentType.TYPES.Note,
},
existingItem,
})
@ -59,11 +70,11 @@ describe('OwnershipFilter', () => {
it('should leave non existing items', async () => {
const result = await createFilter().check({
userUuid: '1-2-3',
userUuid: '00000000-0000-0000-0000-000000000000',
apiVersion: ApiVersion.v20200115,
itemHash: {
uuid: '2-3-4',
content_type: ContentType.Note,
content_type: ContentType.TYPES.Note,
},
existingItem: null,
})
@ -72,4 +83,27 @@ describe('OwnershipFilter', () => {
passed: true,
})
})
it('should return an error if the user uuid is invalid', async () => {
const result = await createFilter().check({
userUuid: 'invalid',
apiVersion: ApiVersion.v20200115,
itemHash: {
uuid: '2-3-4',
content_type: ContentType.TYPES.Note,
},
existingItem,
})
expect(result).toEqual({
passed: false,
conflict: {
unsavedItem: {
uuid: '2-3-4',
content_type: ContentType.TYPES.Note,
},
type: 'uuid_error',
},
})
})
})

View file

@ -2,10 +2,23 @@ import { ItemSaveValidationDTO } from '../SaveValidator/ItemSaveValidationDTO'
import { ItemSaveRuleResult } from './ItemSaveRuleResult'
import { ItemSaveRuleInterface } from './ItemSaveRuleInterface'
import { ConflictType } from '@standardnotes/responses'
import { Uuid } from '@standardnotes/domain-core'
export class OwnershipFilter implements ItemSaveRuleInterface {
async check(dto: ItemSaveValidationDTO): Promise<ItemSaveRuleResult> {
const itemBelongsToADifferentUser = dto.existingItem !== null && dto.existingItem.userUuid !== dto.userUuid
const userUuidOrError = Uuid.create(dto.userUuid)
if (userUuidOrError.isFailed()) {
return {
passed: false,
conflict: {
unsavedItem: dto.itemHash,
type: ConflictType.UuidError,
},
}
}
const userUuid = userUuidOrError.getValue()
const itemBelongsToADifferentUser = dto.existingItem !== null && !dto.existingItem.props.userUuid.equals(userUuid)
if (itemBelongsToADifferentUser) {
return {
passed: false,

View file

@ -1,8 +1,7 @@
import 'reflect-metadata'
import { ContentType } from '@standardnotes/common'
import { Time, Timer, TimerInterface } from '@standardnotes/time'
import { ContentType, Dates, Timestamps, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
import { ApiVersion } from '../../Api/ApiVersion'
@ -26,28 +25,36 @@ describe('TimeDifferenceFilter', () => {
.fn()
.mockImplementation((date: string) => timeHelper.convertStringDateToMicroseconds(date))
existingItem = {
uuid: '1-2-3',
userUuid: '1-2-3',
createdAt: new Date(1616164633241311),
createdAtTimestamp: 1616164633241311,
updatedAt: new Date(1616164633241311),
updatedAtTimestamp: 1616164633241311,
} as jest.Mocked<Item>
existingItem = Item.create(
{
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
updatedWithSession: null,
content: 'foobar',
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()
itemHash = {
uuid: '1-2-3',
content: 'asdqwe1',
content_type: ContentType.Note,
content_type: ContentType.TYPES.Note,
duplicate_of: null,
enc_item_key: 'qweqwe1',
items_key_id: 'asdasd1',
created_at: timeHelper.formatDate(
timeHelper.convertMicrosecondsToDate(existingItem.createdAtTimestamp),
timeHelper.convertMicrosecondsToDate(existingItem.props.timestamps.createdAt),
'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
),
updated_at: timeHelper.formatDate(
timeHelper.convertMicrosecondsToDate(existingItem.updatedAtTimestamp + 1),
timeHelper.convertMicrosecondsToDate(existingItem.props.timestamps.updatedAt + 1),
'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
),
} as jest.Mocked<ItemHash>
@ -83,7 +90,7 @@ describe('TimeDifferenceFilter', () => {
})
it('should filter out items having update at timestamp different in microseconds precision', async () => {
itemHash.updated_at_timestamp = existingItem.updatedAtTimestamp + 1
itemHash.updated_at_timestamp = existingItem.props.timestamps.updatedAt + 1
const result = await createFilter().check({
userUuid: '1-2-3',
@ -102,7 +109,7 @@ describe('TimeDifferenceFilter', () => {
})
it('should leave items having update at timestamp same in microseconds precision', async () => {
itemHash.updated_at_timestamp = existingItem.updatedAtTimestamp
itemHash.updated_at_timestamp = existingItem.props.timestamps.updatedAt
const result = await createFilter().check({
userUuid: '1-2-3',
@ -119,7 +126,9 @@ 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.updatedAtTimestamp) + Time.MicrosecondsInASecond + 1,
timeHelper.convertMicrosecondsToMilliseconds(existingItem.props.timestamps.updatedAt) +
Time.MicrosecondsInASecond +
1,
),
'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
)
@ -142,7 +151,7 @@ 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.updatedAtTimestamp),
timeHelper.convertMicrosecondsToDate(existingItem.props.timestamps.updatedAt),
'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
)
@ -161,7 +170,7 @@ 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.updatedAtTimestamp) +
timeHelper.convertMicrosecondsToMilliseconds(existingItem.props.timestamps.updatedAt) +
Time.MicrosecondsInAMillisecond +
1,
),
@ -186,7 +195,7 @@ 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.updatedAtTimestamp),
timeHelper.convertMicrosecondsToDate(existingItem.props.timestamps.updatedAt),
'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
)

View file

@ -31,7 +31,7 @@ export class TimeDifferenceFilter implements ItemSaveRuleInterface {
}
}
const ourUpdatedAtTimestamp = dto.existingItem.updatedAtTimestamp
const ourUpdatedAtTimestamp = dto.existingItem.props.timestamps.updatedAt
const difference = incomingUpdatedAtTimestamp - ourUpdatedAtTimestamp
if (this.itemHashHasMicrosecondsPrecision(dto.itemHash)) {

View file

@ -1,72 +0,0 @@
import 'reflect-metadata'
import { ContentType } from '@standardnotes/common'
import { ApiVersion } from '../../Api/ApiVersion'
import { Item } from '../Item'
import { UuidFilter } from './UuidFilter'
describe('UuidFilter', () => {
const createFilter = () => new UuidFilter()
it('should filter out items with invalid uuid', async () => {
const invalidUuids = [
'c73bcdcc-2669-4bf6-81d3-e4an73fb11fd',
'c73bcdcc26694bf681d3e4ae73fb11fd',
'definitely-not-a-uuid',
'1-2-3',
'test',
"(select load_file('\\\\\\\\iugt7mazsk477",
'/etc/passwd',
"eval(compile('for x in range(1):\\n i",
]
for (const invalidUuid of invalidUuids) {
const result = await createFilter().check({
userUuid: '1-2-3',
apiVersion: ApiVersion.v20200115,
itemHash: {
uuid: invalidUuid,
content_type: ContentType.Note,
},
existingItem: null,
})
expect(result).toEqual({
passed: false,
conflict: {
unsavedItem: {
uuid: invalidUuid,
content_type: ContentType.Note,
},
type: 'uuid_error',
},
})
}
})
it('should leave items with valid uuid', async () => {
const validUuids = [
'123e4567-e89b-12d3-a456-426655440000',
'c73bcdcc-2669-4bf6-81d3-e4ae73fb11fd',
'C73BCDCC-2669-4Bf6-81d3-E4AE73FB11FD',
]
for (const validUuid of validUuids) {
const result = await createFilter().check({
userUuid: '1-2-3',
apiVersion: ApiVersion.v20200115,
itemHash: {
uuid: validUuid,
content_type: ContentType.Note,
},
existingItem: {} as jest.Mocked<Item>,
})
expect(result).toEqual({
passed: true,
})
}
})
})

View file

@ -1,25 +0,0 @@
import { validate } from 'uuid'
import { ItemSaveValidationDTO } from '../SaveValidator/ItemSaveValidationDTO'
import { ItemSaveRuleResult } from './ItemSaveRuleResult'
import { ItemSaveRuleInterface } from './ItemSaveRuleInterface'
import { ConflictType } from '@standardnotes/responses'
export class UuidFilter implements ItemSaveRuleInterface {
async check(dto: ItemSaveValidationDTO): Promise<ItemSaveRuleResult> {
const validUuid = validate(dto.itemHash.uuid)
if (!validUuid) {
return {
passed: false,
conflict: {
unsavedItem: dto.itemHash,
type: ConflictType.UuidError,
},
}
}
return {
passed: true,
}
}
}

View file

@ -1,13 +1,13 @@
import { ConflictType } from '@standardnotes/responses'
import { ItemHash } from '../ItemHash'
import { ItemProjection } from '../../../Projection/ItemProjection'
import { ItemHttpRepresentation } from '../../../Mapping/Http/ItemHttpRepresentation'
export type SyncResponse20161215 = {
retrieved_items: Array<ItemProjection>
saved_items: Array<ItemProjection>
retrieved_items: Array<ItemHttpRepresentation>
saved_items: Array<ItemHttpRepresentation>
unsaved: Array<{
item: ItemProjection | ItemHash
item: ItemHttpRepresentation | ItemHash
error: {
tag: ConflictType
}

View file

@ -1,11 +1,11 @@
import { ItemConflictProjection } from '../../../Projection/ItemConflictProjection'
import { ItemProjection } from '../../../Projection/ItemProjection'
import { SavedItemProjection } from '../../../Projection/SavedItemProjection'
import { ItemConflictHttpRepresentation } from '../../../Mapping/Http/ItemConflictHttpRepresentation'
import { ItemHttpRepresentation } from '../../../Mapping/Http/ItemHttpRepresentation'
import { SavedItemHttpRepresentation } from '../../../Mapping/Http/SavedItemHttpRepresentation'
export type SyncResponse20200115 = {
retrieved_items: Array<ItemProjection>
saved_items: Array<SavedItemProjection>
conflicts: Array<ItemConflictProjection>
retrieved_items: Array<ItemHttpRepresentation>
saved_items: Array<SavedItemHttpRepresentation>
conflicts: Array<ItemConflictHttpRepresentation>
sync_token: string
cursor_token?: string
}

View file

@ -1,16 +1,16 @@
import 'reflect-metadata'
import { ProjectorInterface } from '../../../Projection/ProjectorInterface'
import { Item } from '../Item'
import { ItemHash } from '../ItemHash'
import { ItemProjection } from '../../../Projection/ItemProjection'
import { SyncResponseFactory20161215 } from './SyncResponseFactory20161215'
import { ConflictType } from '@standardnotes/responses'
import { ContentType, Dates, MapperInterface, Timestamps, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
import { ItemHttpRepresentation } from '../../../Mapping/Http/ItemHttpRepresentation'
describe('SyncResponseFactory20161215', () => {
let itemProjector: ProjectorInterface<Item, ItemProjection>
let item1Projection: ItemProjection
let item2Projection: ItemProjection
let itemProjector: MapperInterface<Item, ItemHttpRepresentation>
let item1Projection: ItemHttpRepresentation
let item2Projection: ItemHttpRepresentation
let item1: Item
let item2: Item
@ -19,30 +19,55 @@ describe('SyncResponseFactory20161215', () => {
beforeEach(() => {
item1Projection = {
uuid: '1-2-3',
} as jest.Mocked<ItemProjection>
} as jest.Mocked<ItemHttpRepresentation>
item2Projection = {
uuid: '2-3-4',
} as jest.Mocked<ItemProjection>
} as jest.Mocked<ItemHttpRepresentation>
itemProjector = {} as jest.Mocked<ProjectorInterface<Item, ItemProjection>>
itemProjector.projectFull = jest.fn().mockImplementation((item: Item) => {
if (item.uuid === '1-2-3') {
itemProjector = {} as jest.Mocked<MapperInterface<Item, ItemHttpRepresentation>>
itemProjector.toProjection = jest.fn().mockImplementation((item: Item) => {
if (item.id.toString() === '00000000-0000-0000-0000-000000000000') {
return item1Projection
} else if (item.uuid === '2-3-4') {
} else if (item.id.toString() === '00000000-0000-0000-0000-000000000001') {
return item2Projection
}
return undefined
})
item1 = {
uuid: '1-2-3',
updatedAtTimestamp: 100,
} as jest.Mocked<Item>
item1 = Item.create(
{
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
updatedWithSession: null,
content: 'foobar',
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 = {
uuid: '2-3-4',
} as jest.Mocked<Item>
item2 = Item.create(
{
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
updatedWithSession: null,
content: 'foobar',
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-000000000001'),
).getValue()
})
it('should turn sync items response into a sync response for API Version 20161215', async () => {
@ -83,10 +108,18 @@ describe('SyncResponseFactory20161215', () => {
it('should pick out conflicts between saved and retrieved items and remove them from the later', async () => {
const itemHash1 = {} as jest.Mocked<ItemHash>
const duplicateItem1 = Object.assign({}, item1)
duplicateItem1.updatedAtTimestamp = item1.updatedAtTimestamp + 21_000_000
const duplicateItem1 = Item.create(
{
...item1.props,
timestamps: Timestamps.create(
item1.props.timestamps.createdAt,
item1.props.timestamps.updatedAt + 21_000_000,
).getValue(),
},
item1.id,
).getValue()
const duplicateItem2 = Object.assign({}, item2)
const duplicateItem2 = Item.create({ ...item2.props }).getValue()
expect(
await createFactory().createResponse({

View file

@ -1,18 +1,18 @@
import { ConflictType } from '@standardnotes/responses'
import { MapperInterface } from '@standardnotes/domain-core'
import { ProjectorInterface } from '../../../Projection/ProjectorInterface'
import { Item } from '../Item'
import { ItemConflict } from '../ItemConflict'
import { ItemHash } from '../ItemHash'
import { ItemProjection } from '../../../Projection/ItemProjection'
import { SyncResponse20161215 } from './SyncResponse20161215'
import { SyncResponseFactoryInterface } from './SyncResponseFactoryInterface'
import { SyncItemsResponse } from '../../UseCase/Syncing/SyncItems/SyncItemsResponse'
import { ItemHttpRepresentation } from '../../../Mapping/Http/ItemHttpRepresentation'
export class SyncResponseFactory20161215 implements SyncResponseFactoryInterface {
private readonly LEGACY_MIN_CONFLICT_INTERVAL = 20_000_000
constructor(private itemProjector: ProjectorInterface<Item, ItemProjection>) {}
constructor(private mapper: MapperInterface<Item, ItemHttpRepresentation>) {}
async createResponse(syncItemsResponse: SyncItemsResponse): Promise<SyncResponse20161215> {
const conflicts = syncItemsResponse.conflicts.filter(
@ -28,9 +28,7 @@ export class SyncResponseFactory20161215 implements SyncResponseFactoryInterface
const unsaved = []
for (const conflict of pickOutConflictsResult.unsavedItems) {
unsaved.push({
item: conflict.serverItem
? <ItemProjection>await this.itemProjector.projectFull(conflict.serverItem)
: <ItemHash>conflict.unsavedItem,
item: conflict.serverItem ? this.mapper.toProjection(conflict.serverItem) : <ItemHash>conflict.unsavedItem,
error: {
tag: conflict.type,
},
@ -39,12 +37,12 @@ export class SyncResponseFactory20161215 implements SyncResponseFactoryInterface
const retrievedItems = []
for (const item of pickOutConflictsResult.retrievedItems) {
retrievedItems.push(<ItemProjection>await this.itemProjector.projectFull(item))
retrievedItems.push(this.mapper.toProjection(item))
}
const savedItems = []
for (const item of syncItemsResponse.savedItems) {
savedItems.push(<ItemProjection>await this.itemProjector.projectFull(item))
savedItems.push(this.mapper.toProjection(item))
}
return {
@ -64,16 +62,16 @@ export class SyncResponseFactory20161215 implements SyncResponseFactoryInterface
unsavedItems: Array<ItemConflict>
retrievedItems: Array<Item>
} {
const savedIds: Array<string> = savedItems.map((savedItem: Item) => savedItem.uuid)
const retrievedIds: Array<string> = retrievedItems.map((retrievedItem: Item) => retrievedItem.uuid)
const savedIds: Array<string> = savedItems.map((savedItem: Item) => savedItem.id.toString())
const retrievedIds: Array<string> = retrievedItems.map((retrievedItem: Item) => retrievedItem.id.toString())
const conflictingIds = savedIds.filter((savedId) => retrievedIds.includes(savedId))
for (const conflictingId of conflictingIds) {
const savedItem = <Item>savedItems.find((item) => item.uuid === conflictingId)
const conflictedItem = <Item>retrievedItems.find((item) => item.uuid === conflictingId)
const savedItem = <Item>savedItems.find((item) => item.id.toString() === conflictingId)
const conflictedItem = <Item>retrievedItems.find((item) => item.id.toString() === conflictingId)
const difference = savedItem.updatedAtTimestamp - conflictedItem.updatedAtTimestamp
const difference = savedItem.props.timestamps.updatedAt - conflictedItem.props.timestamps.updatedAt
if (Math.abs(difference) > this.LEGACY_MIN_CONFLICT_INTERVAL) {
unsavedItems.push({
@ -82,7 +80,7 @@ export class SyncResponseFactory20161215 implements SyncResponseFactoryInterface
})
}
retrievedItems = retrievedItems.filter((retrievedItem: Item) => retrievedItem.uuid !== conflictingId)
retrievedItems = retrievedItems.filter((retrievedItem: Item) => retrievedItem.id.toString() !== conflictingId)
}
return {

View file

@ -1,43 +1,44 @@
import 'reflect-metadata'
import { ProjectorInterface } from '../../../Projection/ProjectorInterface'
import { MapperInterface } from '@standardnotes/domain-core'
import { Item } from '../Item'
import { ItemConflict } from '../ItemConflict'
import { ItemConflictProjection } from '../../../Projection/ItemConflictProjection'
import { ItemProjection } from '../../../Projection/ItemProjection'
import { SyncResponseFactory20200115 } from './SyncResponseFactory20200115'
import { SavedItemProjection } from '../../../Projection/SavedItemProjection'
import { ItemHttpRepresentation } from '../../../Mapping/Http/ItemHttpRepresentation'
import { SavedItemHttpRepresentation } from '../../../Mapping/Http/SavedItemHttpRepresentation'
import { ItemConflictHttpRepresentation } from '../../../Mapping/Http/ItemConflictHttpRepresentation'
describe('SyncResponseFactory20200115', () => {
let itemProjector: ProjectorInterface<Item, ItemProjection>
let savedItemProjector: ProjectorInterface<Item, SavedItemProjection>
let itemConflictProjector: ProjectorInterface<ItemConflict, ItemConflictProjection>
let itemProjection: ItemProjection
let savedItemProjection: SavedItemProjection
let itemConflictProjection: ItemConflictProjection
let itemMapper: MapperInterface<Item, ItemHttpRepresentation>
let savedItemMapper: MapperInterface<Item, SavedItemHttpRepresentation>
let itemConflictMapper: MapperInterface<ItemConflict, ItemConflictHttpRepresentation>
let itemProjection: ItemHttpRepresentation
let savedItemHttpRepresentation: SavedItemHttpRepresentation
let itemConflictProjection: ItemConflictHttpRepresentation
let item1: Item
let item2: Item
let itemConflict: ItemConflict
const createFactory = () => new SyncResponseFactory20200115(itemProjector, itemConflictProjector, savedItemProjector)
const createFactory = () => new SyncResponseFactory20200115(itemMapper, itemConflictMapper, savedItemMapper)
beforeEach(() => {
itemProjection = {
uuid: '2-3-4',
} as jest.Mocked<ItemProjection>
} as jest.Mocked<ItemHttpRepresentation>
itemProjector = {} as jest.Mocked<ProjectorInterface<Item, ItemProjection>>
itemProjector.projectFull = jest.fn().mockReturnValue(itemProjection)
itemMapper = {} as jest.Mocked<MapperInterface<Item, ItemHttpRepresentation>>
itemMapper.toProjection = jest.fn().mockReturnValue(itemProjection)
itemConflictProjector = {} as jest.Mocked<ProjectorInterface<ItemConflict, ItemConflictProjection>>
itemConflictProjector.projectFull = jest.fn().mockReturnValue(itemConflictProjection)
itemConflictMapper = {} as jest.Mocked<MapperInterface<ItemConflict, ItemConflictHttpRepresentation>>
itemConflictMapper.toProjection = jest.fn().mockReturnValue(itemConflictProjection)
savedItemProjection = {
savedItemHttpRepresentation = {
uuid: '1-2-3',
} as jest.Mocked<SavedItemProjection>
} as jest.Mocked<SavedItemHttpRepresentation>
savedItemProjector = {} as jest.Mocked<ProjectorInterface<Item, SavedItemProjection>>
savedItemProjector.projectFull = jest.fn().mockReturnValue(savedItemProjection)
savedItemMapper = {} as jest.Mocked<MapperInterface<Item, SavedItemHttpRepresentation>>
savedItemMapper.toProjection = jest.fn().mockReturnValue(savedItemHttpRepresentation)
item1 = {} as jest.Mocked<Item>
@ -57,7 +58,7 @@ describe('SyncResponseFactory20200115', () => {
}),
).toEqual({
retrieved_items: [itemProjection],
saved_items: [savedItemProjection],
saved_items: [savedItemHttpRepresentation],
conflicts: [itemConflictProjection],
sync_token: 'sync-test',
cursor_token: 'cursor-test',

View file

@ -1,34 +1,35 @@
import { ProjectorInterface } from '../../../Projection/ProjectorInterface'
import { MapperInterface } from '@standardnotes/domain-core'
import { Item } from '../Item'
import { ItemConflict } from '../ItemConflict'
import { ItemConflictProjection } from '../../../Projection/ItemConflictProjection'
import { ItemProjection } from '../../../Projection/ItemProjection'
import { SyncResponse20200115 } from './SyncResponse20200115'
import { SyncResponseFactoryInterface } from './SyncResponseFactoryInterface'
import { SavedItemProjection } from '../../../Projection/SavedItemProjection'
import { SyncItemsResponse } from '../../UseCase/Syncing/SyncItems/SyncItemsResponse'
import { ItemHttpRepresentation } from '../../../Mapping/Http/ItemHttpRepresentation'
import { ItemConflictHttpRepresentation } from '../../../Mapping/Http/ItemConflictHttpRepresentation'
import { SavedItemHttpRepresentation } from '../../../Mapping/Http/SavedItemHttpRepresentation'
export class SyncResponseFactory20200115 implements SyncResponseFactoryInterface {
constructor(
private itemProjector: ProjectorInterface<Item, ItemProjection>,
private itemConflictProjector: ProjectorInterface<ItemConflict, ItemConflictProjection>,
private savedItemProjector: ProjectorInterface<Item, SavedItemProjection>,
private httpMapper: MapperInterface<Item, ItemHttpRepresentation>,
private itemConflictMapper: MapperInterface<ItemConflict, ItemConflictHttpRepresentation>,
private savedItemMapper: MapperInterface<Item, SavedItemHttpRepresentation>,
) {}
async createResponse(syncItemsResponse: SyncItemsResponse): Promise<SyncResponse20200115> {
const retrievedItems = []
for (const item of syncItemsResponse.retrievedItems) {
retrievedItems.push(<ItemProjection>await this.itemProjector.projectFull(item))
retrievedItems.push(this.httpMapper.toProjection(item))
}
const savedItems = []
for (const item of syncItemsResponse.savedItems) {
savedItems.push(<SavedItemProjection>await this.savedItemProjector.projectFull(item))
savedItems.push(this.savedItemMapper.toProjection(item))
}
const conflicts = []
for (const itemConflict of syncItemsResponse.conflicts) {
conflicts.push(<ItemConflictProjection>await this.itemConflictProjector.projectFull(itemConflict))
conflicts.push(this.itemConflictMapper.toProjection(itemConflict))
}
return {

View file

@ -1,9 +1,10 @@
import 'reflect-metadata'
import { ContentType } from '@standardnotes/domain-core'
import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
import { CheckIntegrity } from './CheckIntegrity'
import { ContentType } from '@standardnotes/common'
describe('CheckIntegrity', () => {
let itemRepository: ItemRepositoryInterface
@ -16,27 +17,27 @@ describe('CheckIntegrity', () => {
{
uuid: '1-2-3',
updated_at_timestamp: 1,
content_type: ContentType.Note,
content_type: ContentType.TYPES.Note,
},
{
uuid: '2-3-4',
updated_at_timestamp: 2,
content_type: ContentType.Note,
content_type: ContentType.TYPES.Note,
},
{
uuid: '3-4-5',
updated_at_timestamp: 3,
content_type: ContentType.Note,
content_type: ContentType.TYPES.Note,
},
{
uuid: '4-5-6',
updated_at_timestamp: 4,
content_type: ContentType.ItemsKey,
content_type: ContentType.TYPES.ItemsKey,
},
{
uuid: '5-6-7',
updated_at_timestamp: 5,
content_type: ContentType.File,
content_type: ContentType.TYPES.File,
},
])
})

View file

@ -1,6 +1,5 @@
import { IntegrityPayload } from '@standardnotes/responses'
import { Result, UseCaseInterface } from '@standardnotes/domain-core'
import { ContentType } from '@standardnotes/common'
import { ContentType, Result, UseCaseInterface } from '@standardnotes/domain-core'
import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
import { CheckIntegrityDTO } from './CheckIntegrityDTO'
@ -33,7 +32,7 @@ export class CheckIntegrity implements UseCaseInterface<IntegrityPayload[]> {
) as ExtendedIntegrityPayload
if (!clientItemIntegrityPayloadsMap.has(serverItemIntegrityPayloadUuid)) {
if (serverItemIntegrityPayload.content_type !== ContentType.ItemsKey) {
if (serverItemIntegrityPayload.content_type !== ContentType.TYPES.ItemsKey) {
mismatches.unshift({
uuid: serverItemIntegrityPayloadUuid,
updated_at_timestamp: serverItemIntegrityPayload.updated_at_timestamp,
@ -49,7 +48,7 @@ export class CheckIntegrity implements UseCaseInterface<IntegrityPayload[]> {
) as number
if (
serverItemIntegrityPayloadUpdatedAtTimestamp !== clientItemIntegrityPayloadUpdatedAtTimestamp &&
serverItemIntegrityPayload.content_type !== ContentType.ItemsKey
serverItemIntegrityPayload.content_type !== ContentType.TYPES.ItemsKey
) {
mismatches.unshift({
uuid: serverItemIntegrityPayloadUuid,

View file

@ -0,0 +1,264 @@
import { Timer, TimerInterface } from '@standardnotes/time'
import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
import { SaveNewItem } from './SaveNewItem'
import { DomainEventInterface, DomainEventPublisherInterface } from '@standardnotes/domain-events'
import { DomainEventFactoryInterface } from '../../../Event/DomainEventFactoryInterface'
import { ItemHash } from '../../../Item/ItemHash'
import { ContentType, Dates, Result, Timestamps, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
import { Item } from '../../../Item/Item'
describe('SaveNewItem', () => {
let itemRepository: ItemRepositoryInterface
let timer: TimerInterface
let domainEventPublisher: DomainEventPublisherInterface
let domainEventFactory: DomainEventFactoryInterface
let itemHash1: ItemHash
let item1: Item
const createUseCase = () => new SaveNewItem(itemRepository, timer, domainEventPublisher, domainEventFactory)
beforeEach(() => {
const timeHelper = new Timer()
item1 = Item.create(
{
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
updatedWithSession: null,
content: 'foobar',
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()
itemHash1 = {
uuid: '1-2-3',
content: 'asdqwe1',
content_type: ContentType.TYPES.Note,
duplicate_of: null,
enc_item_key: 'qweqwe1',
items_key_id: 'asdasd1',
created_at: timeHelper.formatDate(
timeHelper.convertMicrosecondsToDate(item1.props.timestamps.createdAt),
'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
),
updated_at: timeHelper.formatDate(
new Date(timeHelper.convertMicrosecondsToMilliseconds(item1.props.timestamps.updatedAt) + 1),
'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
),
} as jest.Mocked<ItemHash>
itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
itemRepository.save = jest.fn()
timer = {} as jest.Mocked<TimerInterface>
timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123456789)
timer.convertMicrosecondsToDate = jest.fn().mockReturnValue(new Date(123456789))
timer.convertStringDateToMicroseconds = jest.fn().mockReturnValue(123456789)
timer.convertStringDateToDate = jest.fn().mockReturnValue(new Date(123456789))
domainEventPublisher = {} as jest.Mocked<DomainEventPublisherInterface>
domainEventPublisher.publish = jest.fn()
domainEventFactory = {} as jest.Mocked<DomainEventFactoryInterface>
domainEventFactory.createDuplicateItemSyncedEvent = jest
.fn()
.mockReturnValue({} as jest.Mocked<DomainEventInterface>)
domainEventFactory.createItemRevisionCreationRequested = jest
.fn()
.mockReturnValue({} as jest.Mocked<DomainEventInterface>)
})
it('saves a new item', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
sessionUuid: '00000000-0000-0000-0000-000000000001',
itemHash: itemHash1,
})
expect(result.isFailed()).toBeFalsy()
expect(itemRepository.save).toHaveBeenCalled()
})
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
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
sessionUuid: '00000000-0000-0000-0000-000000000001',
itemHash: itemHash1,
})
expect(result.isFailed()).toBeFalsy()
expect(itemRepository.save).toHaveBeenCalled()
})
it('saves a new item with given timestamps', async () => {
const useCase = createUseCase()
itemHash1.created_at_timestamp = 123
itemHash1.updated_at_timestamp = 123
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
sessionUuid: '00000000-0000-0000-0000-000000000001',
itemHash: itemHash1,
})
expect(result.isFailed()).toBeFalsy()
})
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'
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
sessionUuid: '00000000-0000-0000-0000-000000000001',
itemHash: itemHash1,
})
expect(result.isFailed()).toBeFalsy()
expect(domainEventFactory.createDuplicateItemSyncedEvent).toHaveBeenCalled()
expect(domainEventPublisher.publish).toHaveBeenCalled()
})
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'
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
sessionUuid: '00000000-0000-0000-0000-000000000001',
itemHash: itemHash1,
})
expect(result.isFailed()).toBeFalsy()
expect(domainEventFactory.createItemRevisionCreationRequested).toHaveBeenCalled()
expect(domainEventPublisher.publish).toHaveBeenCalled()
})
it('returns a failure if the item cannot be saved', async () => {
const mock = jest.spyOn(Item, 'create')
mock.mockImplementation(() => {
return Result.fail('Oops')
})
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
sessionUuid: '00000000-0000-0000-0000-000000000001',
itemHash: itemHash1,
})
expect(result.isFailed()).toBeTruthy()
mock.mockRestore()
})
it('returns a failure if the user uuid is invalid', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-00000000000',
sessionUuid: '00000000-0000-0000-0000-000000000001',
itemHash: itemHash1,
})
expect(result.isFailed()).toBeTruthy()
})
it('returns a failure if the session uuid is invalid', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
sessionUuid: '00000000-0000-0000-0000-00000000000',
itemHash: itemHash1,
})
expect(result.isFailed()).toBeTruthy()
})
it('returns a failure if the content type is invalid', async () => {
const useCase = createUseCase()
itemHash1.content_type = 'invalid'
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
sessionUuid: '00000000-0000-0000-0000-000000000001',
itemHash: itemHash1,
})
expect(result.isFailed()).toBeTruthy()
})
it('returns a failure if the duplicate uuid is invalid', async () => {
const useCase = createUseCase()
itemHash1.duplicate_of = 'invalid'
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
sessionUuid: '00000000-0000-0000-0000-000000000001',
itemHash: itemHash1,
})
expect(result.isFailed()).toBeTruthy()
})
it('returns a failure if it fails to create dates', async () => {
const mock = jest.spyOn(Dates, 'create')
mock.mockImplementation(() => {
return Result.fail('Oops')
})
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
sessionUuid: '00000000-0000-0000-0000-000000000001',
itemHash: itemHash1,
})
expect(result.isFailed()).toBeTruthy()
mock.mockRestore()
})
it('return a failure if it fails to create timestamps', async () => {
const mock = jest.spyOn(Timestamps, 'create')
mock.mockImplementation(() => {
return Result.fail('Oops')
})
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
sessionUuid: '00000000-0000-0000-0000-000000000001',
itemHash: itemHash1,
})
expect(result.isFailed()).toBeTruthy()
mock.mockRestore()
})
})

View file

@ -0,0 +1,121 @@
import {
ContentType,
Dates,
Result,
Timestamps,
UniqueEntityId,
UseCaseInterface,
Uuid,
} from '@standardnotes/domain-core'
import { TimerInterface } from '@standardnotes/time'
import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
import { Item } from '../../../Item/Item'
import { SaveNewItemDTO } from './SaveNewItemDTO'
import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
import { DomainEventFactoryInterface } from '../../../Event/DomainEventFactoryInterface'
export class SaveNewItem implements UseCaseInterface<Item> {
constructor(
private itemRepository: ItemRepositoryInterface,
private timer: TimerInterface,
private domainEventPublisher: DomainEventPublisherInterface,
private domainEventFactory: DomainEventFactoryInterface,
) {}
async execute(dto: SaveNewItemDTO): Promise<Result<Item>> {
let updatedWithSession = null
if (dto.sessionUuid) {
const sessionUuidOrError = Uuid.create(dto.sessionUuid)
if (sessionUuidOrError.isFailed()) {
return Result.fail(sessionUuidOrError.getError())
}
updatedWithSession = sessionUuidOrError.getValue()
}
const userUuidOrError = Uuid.create(dto.userUuid)
if (userUuidOrError.isFailed()) {
return Result.fail(userUuidOrError.getError())
}
const userUuid = userUuidOrError.getValue()
const contentTypeOrError = ContentType.create(dto.itemHash.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 (duplicateOfOrError.isFailed()) {
return Result.fail(duplicateOfOrError.getError())
}
duplicateOf = duplicateOfOrError.getValue()
}
const now = this.timer.getTimestampInMicroseconds()
const nowDate = this.timer.convertMicrosecondsToDate(now)
let createdAtDate = nowDate
let createdAtTimestamp = now
if (dto.itemHash.created_at_timestamp) {
createdAtTimestamp = dto.itemHash.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)
}
const datesOrError = Dates.create(createdAtDate, nowDate)
if (datesOrError.isFailed()) {
return Result.fail(datesOrError.getError())
}
const dates = datesOrError.getValue()
const timestampsOrError = Timestamps.create(createdAtTimestamp, now)
if (timestampsOrError.isFailed()) {
return Result.fail(timestampsOrError.getError())
}
const timestamps = timestampsOrError.getValue()
const itemOrError = Item.create(
{
updatedWithSession,
content: dto.itemHash.content ?? null,
userUuid,
contentType,
encItemKey: dto.itemHash.enc_item_key ?? null,
authHash: dto.itemHash.auth_hash ?? null,
itemsKeyId: dto.itemHash.items_key_id ?? null,
duplicateOf,
deleted: dto.itemHash.deleted ?? false,
dates,
timestamps,
},
new UniqueEntityId(dto.itemHash.uuid),
)
if (itemOrError.isFailed()) {
return Result.fail(itemOrError.getError())
}
const newItem = itemOrError.getValue()
await this.itemRepository.save(newItem)
if (contentType.value !== null && [ContentType.TYPES.Note, ContentType.TYPES.File].includes(contentType.value)) {
await this.domainEventPublisher.publish(
this.domainEventFactory.createItemRevisionCreationRequested(
newItem.id.toString(),
newItem.props.userUuid.value,
),
)
}
if (duplicateOf) {
await this.domainEventPublisher.publish(
this.domainEventFactory.createDuplicateItemSyncedEvent(newItem.id.toString(), newItem.props.userUuid.value),
)
}
return Result.ok(newItem)
}
}

View file

@ -0,0 +1,7 @@
import { ItemHash } from '../../../Item/ItemHash'
export interface SaveNewItemDTO {
userUuid: string
itemHash: ItemHash
sessionUuid: string | null
}

View file

@ -1,13 +1,12 @@
import 'reflect-metadata'
import { ContentType } from '@standardnotes/common'
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'
describe('SyncItems', () => {
let itemService: ItemServiceInterface
@ -19,20 +18,59 @@ describe('SyncItems', () => {
const createUseCase = () => new SyncItems(itemService)
beforeEach(() => {
item1 = {
uuid: '1-2-3',
} as jest.Mocked<Item>
item2 = {
uuid: '2-3-4',
} as jest.Mocked<Item>
item3 = {
uuid: '3-4-5',
} as jest.Mocked<Item>
item1 = Item.create(
{
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
updatedWithSession: null,
content: 'foobar',
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-000000000001'),
).getValue()
item2 = Item.create(
{
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
updatedWithSession: null,
content: 'foobar',
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-000000000002'),
).getValue()
item3 = Item.create(
{
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
updatedWithSession: null,
content: 'foobar',
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()
itemHash = {
uuid: '2-3-4',
content: 'asdqwe',
content_type: ContentType.Note,
content_type: ContentType.TYPES.Note,
duplicate_of: null,
enc_item_key: 'qweqwe',
items_key_id: 'asdasd',

View file

@ -1,4 +1,5 @@
import { Result, UseCaseInterface } from '@standardnotes/domain-core'
import { Item } from '../../../Item/Item'
import { ItemConflict } from '../../../Item/ItemConflict'
import { ItemServiceInterface } from '../../../Item/ItemServiceInterface'
@ -52,10 +53,10 @@ export class SyncItems implements UseCaseInterface<SyncItemsResponse> {
const syncConflictIds: Array<string> = []
conflicts.forEach((conflict: ItemConflict) => {
if (conflict.type === 'sync_conflict' && conflict.serverItem) {
syncConflictIds.push(conflict.serverItem.uuid)
syncConflictIds.push(conflict.serverItem.id.toString())
}
})
return retrievedItems.filter((item: Item) => syncConflictIds.indexOf(item.uuid) === -1)
return retrievedItems.filter((item: Item) => syncConflictIds.indexOf(item.id.toString()) === -1)
}
}

View file

@ -0,0 +1,251 @@
import { DomainEventInterface, DomainEventPublisherInterface } from '@standardnotes/domain-events'
import { Timer, TimerInterface } from '@standardnotes/time'
import { DomainEventFactoryInterface } from '../../../Event/DomainEventFactoryInterface'
import { Item } from '../../../Item/Item'
import { ItemHash } from '../../../Item/ItemHash'
import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
import { UpdateExistingItem } from './UpdateExistingItem'
import { Uuid, ContentType, Dates, Timestamps, UniqueEntityId, Result } from '@standardnotes/domain-core'
describe('UpdateExistingItem', () => {
let itemRepository: ItemRepositoryInterface
let timer: TimerInterface
let domainEventPublisher: DomainEventPublisherInterface
let domainEventFactory: DomainEventFactoryInterface
let itemHash1: ItemHash
let item1: Item
const createUseCase = () => new UpdateExistingItem(itemRepository, timer, domainEventPublisher, domainEventFactory, 5)
beforeEach(() => {
const timeHelper = new Timer()
item1 = Item.create(
{
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
updatedWithSession: null,
content: 'foobar',
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()
itemHash1 = {
uuid: '1-2-3',
content: 'asdqwe1',
content_type: ContentType.TYPES.Note,
duplicate_of: null,
enc_item_key: 'qweqwe1',
auth_hash: 'auth_hash',
items_key_id: 'asdasd1',
created_at: timeHelper.formatDate(
timeHelper.convertMicrosecondsToDate(item1.props.timestamps.createdAt),
'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
),
updated_at: timeHelper.formatDate(
new Date(timeHelper.convertMicrosecondsToMilliseconds(item1.props.timestamps.updatedAt) + 1),
'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
),
} as jest.Mocked<ItemHash>
itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
itemRepository.save = jest.fn()
timer = {} as jest.Mocked<TimerInterface>
timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123456789)
timer.convertMicrosecondsToDate = jest.fn().mockReturnValue(new Date(123456789))
timer.convertStringDateToMicroseconds = jest.fn().mockReturnValue(123456789)
timer.convertMicrosecondsToSeconds = jest.fn().mockReturnValue(123456789)
timer.convertStringDateToDate = jest.fn().mockReturnValue(new Date(123456789))
domainEventPublisher = {} as jest.Mocked<DomainEventPublisherInterface>
domainEventPublisher.publish = jest.fn()
domainEventFactory = {} as jest.Mocked<DomainEventFactoryInterface>
domainEventFactory.createDuplicateItemSyncedEvent = jest
.fn()
.mockReturnValue({} as jest.Mocked<DomainEventInterface>)
domainEventFactory.createItemRevisionCreationRequested = jest
.fn()
.mockReturnValue({} as jest.Mocked<DomainEventInterface>)
})
it('should update item', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
existingItem: item1,
itemHash: itemHash1,
sessionUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeFalsy()
expect(itemRepository.save).toHaveBeenCalled()
})
it('should return error if session uuid is invalid', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
existingItem: item1,
itemHash: itemHash1,
sessionUuid: 'invalid-uuid',
})
expect(result.isFailed()).toBeTruthy()
})
it('should return error if content type is invalid', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
existingItem: item1,
itemHash: {
...itemHash1,
content_type: 'invalid',
},
sessionUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
})
it('should mark item as deleted if item hash is deleted', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
existingItem: item1,
itemHash: {
...itemHash1,
deleted: true,
},
sessionUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeFalsy()
expect(itemRepository.save).toHaveBeenCalled()
expect(item1.props.deleted).toBeTruthy()
expect(item1.props.content).toBeNull()
expect(item1.props.encItemKey).toBeNull()
expect(item1.props.authHash).toBeNull()
expect(item1.props.itemsKeyId).toBeNull()
expect(item1.props.duplicateOf).toBeNull()
})
it('should mark item as duplicate if item hash has duplicate_of', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
existingItem: item1,
itemHash: {
...itemHash1,
duplicate_of: '00000000-0000-0000-0000-000000000001',
},
sessionUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeFalsy()
expect(itemRepository.save).toHaveBeenCalled()
expect(item1.props.duplicateOf?.value).toBe('00000000-0000-0000-0000-000000000001')
})
it('shuld return error if duplicate uuid is invalid', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
existingItem: item1,
itemHash: {
...itemHash1,
duplicate_of: 'invalid-uuid',
},
sessionUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
})
it('should update item with update timestamps', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
existingItem: item1,
itemHash: {
...itemHash1,
updated_at_timestamp: 123,
created_at_timestamp: 123,
},
sessionUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeFalsy()
expect(itemRepository.save).toHaveBeenCalled()
})
it('should return error if created at time is not give in any form', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
existingItem: item1,
itemHash: {
...itemHash1,
created_at: undefined,
created_at_timestamp: undefined,
},
sessionUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
})
it('should return error if dates could not be created from timestamps', async () => {
const mock = jest.spyOn(Dates, 'create')
mock.mockImplementation(() => {
return Result.fail('Oops')
})
const useCase = createUseCase()
const result = await useCase.execute({
existingItem: item1,
itemHash: {
...itemHash1,
created_at_timestamp: 123,
updated_at_timestamp: 123,
},
sessionUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
mock.mockRestore()
})
it('should return error if timestamps could not be created from timestamps', async () => {
const mock = jest.spyOn(Timestamps, 'create')
mock.mockImplementation(() => {
return Result.fail('Oops')
})
const useCase = createUseCase()
const result = await useCase.execute({
existingItem: item1,
itemHash: {
...itemHash1,
created_at_timestamp: 123,
updated_at_timestamp: 123,
},
sessionUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
mock.mockRestore()
})
})

View file

@ -0,0 +1,135 @@
import { ContentType, Dates, Result, Timestamps, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
import { TimerInterface } from '@standardnotes/time'
import { Item } from '../../../Item/Item'
import { UpdateExistingItemDTO } from './UpdateExistingItemDTO'
import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
import { DomainEventFactoryInterface } from '../../../Event/DomainEventFactoryInterface'
export class UpdateExistingItem implements UseCaseInterface<Item> {
constructor(
private itemRepository: ItemRepositoryInterface,
private timer: TimerInterface,
private domainEventPublisher: DomainEventPublisherInterface,
private domainEventFactory: DomainEventFactoryInterface,
private revisionFrequency: number,
) {}
async execute(dto: UpdateExistingItemDTO): Promise<Result<Item>> {
let sessionUuid = null
if (dto.sessionUuid) {
const sessionUuidOrError = Uuid.create(dto.sessionUuid)
if (sessionUuidOrError.isFailed()) {
return Result.fail(sessionUuidOrError.getError())
}
sessionUuid = sessionUuidOrError.getValue()
}
dto.existingItem.props.updatedWithSession = sessionUuid
if (dto.itemHash.content) {
dto.existingItem.props.content = dto.itemHash.content
}
if (dto.itemHash.content_type) {
const contentTypeOrError = ContentType.create(dto.itemHash.content_type)
if (contentTypeOrError.isFailed()) {
return Result.fail(contentTypeOrError.getError())
}
const contentType = contentTypeOrError.getValue()
dto.existingItem.props.contentType = contentType
}
if (dto.itemHash.deleted !== undefined) {
dto.existingItem.props.deleted = dto.itemHash.deleted
}
let wasMarkedAsDuplicate = false
if (dto.itemHash.duplicate_of) {
const duplicateOfOrError = Uuid.create(dto.itemHash.duplicate_of)
if (duplicateOfOrError.isFailed()) {
return Result.fail(duplicateOfOrError.getError())
}
wasMarkedAsDuplicate = dto.existingItem.props.duplicateOf === null
dto.existingItem.props.duplicateOf = duplicateOfOrError.getValue()
}
if (dto.itemHash.auth_hash) {
dto.existingItem.props.authHash = dto.itemHash.auth_hash
}
if (dto.itemHash.enc_item_key) {
dto.existingItem.props.encItemKey = dto.itemHash.enc_item_key
}
if (dto.itemHash.items_key_id) {
dto.existingItem.props.itemsKeyId = dto.itemHash.items_key_id
}
const updatedAtTimestamp = this.timer.getTimestampInMicroseconds()
const secondsFromLastUpdate = this.timer.convertMicrosecondsToSeconds(
updatedAtTimestamp - dto.existingItem.props.timestamps.updatedAt,
)
const updatedAtDate = this.timer.convertMicrosecondsToDate(updatedAtTimestamp)
let createdAtTimestamp: number
let createdAtDate: Date
if (dto.itemHash.created_at_timestamp) {
createdAtTimestamp = dto.itemHash.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 {
return Result.fail('Created at timestamp is required.')
}
const datesOrError = Dates.create(createdAtDate, updatedAtDate)
if (datesOrError.isFailed()) {
return Result.fail(datesOrError.getError())
}
dto.existingItem.props.dates = datesOrError.getValue()
const timestampsOrError = Timestamps.create(createdAtTimestamp, updatedAtTimestamp)
if (timestampsOrError.isFailed()) {
return Result.fail(timestampsOrError.getError())
}
dto.existingItem.props.timestamps = timestampsOrError.getValue()
dto.existingItem.props.contentSize = Buffer.byteLength(JSON.stringify(dto.existingItem))
if (dto.itemHash.deleted === true) {
dto.existingItem.props.deleted = true
dto.existingItem.props.content = null
dto.existingItem.props.contentSize = 0
dto.existingItem.props.encItemKey = null
dto.existingItem.props.authHash = null
dto.existingItem.props.itemsKeyId = null
}
await this.itemRepository.save(dto.existingItem)
if (secondsFromLastUpdate >= this.revisionFrequency) {
if (
dto.existingItem.props.contentType.value !== null &&
[ContentType.TYPES.Note, ContentType.TYPES.File].includes(dto.existingItem.props.contentType.value)
) {
await this.domainEventPublisher.publish(
this.domainEventFactory.createItemRevisionCreationRequested(
dto.existingItem.id.toString(),
dto.existingItem.props.userUuid.value,
),
)
}
}
if (wasMarkedAsDuplicate) {
await this.domainEventPublisher.publish(
this.domainEventFactory.createDuplicateItemSyncedEvent(
dto.existingItem.id.toString(),
dto.existingItem.props.userUuid.value,
),
)
}
return Result.ok(dto.existingItem)
}
}

View file

@ -0,0 +1,8 @@
import { Item } from '../../../Item/Item'
import { ItemHash } from '../../../Item/ItemHash'
export interface UpdateExistingItemDTO {
existingItem: Item
itemHash: ItemHash
sessionUuid: string | null
}

View file

@ -1,4 +1,5 @@
import { KeyParamsData } from '@standardnotes/responses'
import { MapperInterface } from '@standardnotes/domain-core'
import { promises } from 'fs'
import * as uuid from 'uuid'
import { Logger } from 'winston'
@ -6,13 +7,12 @@ import { dirname } from 'path'
import { Item } from '../../Domain/Item/Item'
import { ItemBackupServiceInterface } from '../../Domain/Item/ItemBackupServiceInterface'
import { ItemProjection } from '../../Projection/ItemProjection'
import { ProjectorInterface } from '../../Projection/ProjectorInterface'
import { ItemBackupRepresentation } from '../../Mapping/Backup/ItemBackupRepresentation'
export class FSItemBackupService implements ItemBackupServiceInterface {
constructor(
private fileUploadPath: string,
private itemProjector: ProjectorInterface<Item, ItemProjection>,
private mapper: MapperInterface<Item, ItemBackupRepresentation>,
private logger: Logger,
) {}
@ -22,12 +22,12 @@ export class FSItemBackupService implements ItemBackupServiceInterface {
async dump(item: Item): Promise<string> {
const contents = JSON.stringify({
item: await this.itemProjector.projectCustom('dump', item),
item: this.mapper.toProjection(item),
})
const path = `${this.fileUploadPath}/dumps/${uuid.v4()}`
this.logger.debug(`Dumping item ${item.uuid} to ${path}`)
this.logger.debug(`Dumping item ${item.id.toString()} to ${path}`)
await promises.mkdir(dirname(path), { recursive: true })

View file

@ -1,4 +1,4 @@
import { ControllerContainerInterface } from '@standardnotes/domain-core'
import { ControllerContainerInterface, MapperInterface } from '@standardnotes/domain-core'
import { BaseHttpController, results } from 'inversify-express-utils'
import { Request, Response } from 'express'
@ -6,18 +6,17 @@ import { Item } from '../../../Domain/Item/Item'
import { SyncResponseFactoryResolverInterface } from '../../../Domain/Item/SyncResponse/SyncResponseFactoryResolverInterface'
import { CheckIntegrity } from '../../../Domain/UseCase/Syncing/CheckIntegrity/CheckIntegrity'
import { GetItem } from '../../../Domain/UseCase/Syncing/GetItem/GetItem'
import { ItemProjection } from '../../../Projection/ItemProjection'
import { ProjectorInterface } from '../../../Projection/ProjectorInterface'
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'
export class HomeServerItemsController extends BaseHttpController {
constructor(
protected syncItems: SyncItems,
protected checkIntegrity: CheckIntegrity,
protected getItem: GetItem,
protected itemProjector: ProjectorInterface<Item, ItemProjection>,
protected itemHttpMapper: MapperInterface<Item, ItemHttpRepresentation>,
protected syncResponseFactoryResolver: SyncResponseFactoryResolverInterface,
private controllerContainer?: ControllerContainerInterface,
) {
@ -92,6 +91,6 @@ export class HomeServerItemsController extends BaseHttpController {
return this.json({ error: { message: result.getError() } }, 404)
}
return this.json({ item: await this.itemProjector.projectFull(result.getValue()) })
return this.json({ item: this.itemHttpMapper.toProjection(result.getValue()) })
}
}

View file

@ -1,14 +1,11 @@
import 'reflect-metadata'
import * as express from 'express'
import { ContentType } from '@standardnotes/common'
import { Result } from '@standardnotes/domain-core'
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 { ItemProjection } from '../../Projection/ItemProjection'
import { ProjectorInterface } from '../../Projection/ProjectorInterface'
import { ApiVersion } from '../../Domain/Api/ApiVersion'
import { SyncResponse20200115 } from '../../Domain/Item/SyncResponse/SyncResponse20200115'
import { SyncResponseFactoryInterface } from '../../Domain/Item/SyncResponse/SyncResponseFactoryInterface'
@ -16,12 +13,13 @@ import { SyncResponseFactoryResolverInterface } from '../../Domain/Item/SyncResp
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 itemProjector: ProjectorInterface<Item, ItemProjection>
let mapper: MapperInterface<Item, ItemHttpRepresentation>
let request: express.Request
let response: express.Response
let syncResponceFactoryResolver: SyncResponseFactoryResolverInterface
@ -29,11 +27,11 @@ describe('InversifyExpressItemsController', () => {
let syncResponse: SyncResponse20200115
const createController = () =>
new InversifyExpressItemsController(syncItems, checkIntegrity, getItem, itemProjector, syncResponceFactoryResolver)
new InversifyExpressItemsController(syncItems, checkIntegrity, getItem, mapper, syncResponceFactoryResolver)
beforeEach(() => {
itemProjector = {} as jest.Mocked<ProjectorInterface<Item, ItemProjection>>
itemProjector.projectFull = jest.fn().mockReturnValue({ foo: 'bar' })
mapper = {} as jest.Mocked<MapperInterface<Item, ItemHttpRepresentation>>
mapper.toProjection = jest.fn().mockReturnValue({ foo: 'bar' })
syncItems = {} as jest.Mocked<SyncItems>
syncItems.execute = jest.fn().mockReturnValue(Result.ok({ foo: 'bar' }))
@ -58,7 +56,7 @@ describe('InversifyExpressItemsController', () => {
request.body.items = [
{
content: 'test',
content_type: ContentType.Note,
content_type: ContentType.TYPES.Note,
created_at: '2021-02-19T11:35:45.655Z',
deleted: false,
duplicate_of: null,

View file

@ -8,9 +8,9 @@ import { SyncResponseFactoryResolverInterface } from '../../Domain/Item/SyncResp
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 { ItemProjection } from '../../Projection/ItemProjection'
import { ProjectorInterface } from '../../Projection/ProjectorInterface'
import { HomeServerItemsController } from './HomeServer/HomeServerItemsController'
import { MapperInterface } from '@standardnotes/domain-core'
import { ItemHttpRepresentation } from '../../Mapping/Http/ItemHttpRepresentation'
@controller('/items', TYPES.Sync_AuthMiddleware)
export class InversifyExpressItemsController extends HomeServerItemsController {
@ -18,11 +18,11 @@ export class InversifyExpressItemsController extends HomeServerItemsController {
@inject(TYPES.Sync_SyncItems) override syncItems: SyncItems,
@inject(TYPES.Sync_CheckIntegrity) override checkIntegrity: CheckIntegrity,
@inject(TYPES.Sync_GetItem) override getItem: GetItem,
@inject(TYPES.Sync_ItemProjector) override itemProjector: ProjectorInterface<Item, ItemProjection>,
@inject(TYPES.Sync_ItemHttpMapper) override itemHttpMapper: MapperInterface<Item, ItemHttpRepresentation>,
@inject(TYPES.Sync_SyncResponseFactoryResolver)
override syncResponseFactoryResolver: SyncResponseFactoryResolverInterface,
) {
super(syncItems, checkIntegrity, getItem, itemProjector, syncResponseFactoryResolver)
super(syncItems, checkIntegrity, getItem, itemHttpMapper, syncResponseFactoryResolver)
}
@httpPost('/sync')

View file

@ -5,13 +5,15 @@ import { Logger } from 'winston'
import { Item } from '../../Domain/Item/Item'
import { ItemBackupServiceInterface } from '../../Domain/Item/ItemBackupServiceInterface'
import { ProjectorInterface } from '../../Projection/ProjectorInterface'
import { ItemProjection } from '../../Projection/ItemProjection'
import { MapperInterface } from '@standardnotes/domain-core'
import { ItemBackupRepresentation } from '../../Mapping/Backup/ItemBackupRepresentation'
import { ItemHttpRepresentation } from '../../Mapping/Http/ItemHttpRepresentation'
export class S3ItemBackupService implements ItemBackupServiceInterface {
constructor(
private s3BackupBucketName: string,
private itemProjector: ProjectorInterface<Item, ItemProjection>,
private backupMapper: MapperInterface<Item, ItemBackupRepresentation>,
private httpMapper: MapperInterface<Item, ItemHttpRepresentation>,
private logger: Logger,
private s3Client?: S3Client,
) {}
@ -29,7 +31,7 @@ export class S3ItemBackupService implements ItemBackupServiceInterface {
Bucket: this.s3BackupBucketName,
Key: s3Key,
Body: JSON.stringify({
item: await this.itemProjector.projectCustom('dump', item),
item: this.backupMapper.toProjection(item),
}),
}),
)
@ -45,10 +47,10 @@ export class S3ItemBackupService implements ItemBackupServiceInterface {
}
const fileNames = []
let itemProjections: Array<ItemProjection> = []
let itemProjections: Array<ItemHttpRepresentation> = []
let contentSizeCounter = 0
for (const item of items) {
const itemProjection = await this.itemProjector.projectFull(item)
const itemProjection = this.httpMapper.toProjection(item)
if (contentSizeLimit === undefined) {
itemProjections.push(itemProjection)
@ -79,7 +81,10 @@ export class S3ItemBackupService implements ItemBackupServiceInterface {
return fileNames
}
private async createBackupFile(itemProjections: ItemProjection[], authParams: KeyParamsData): Promise<string> {
private async createBackupFile(
itemRepresentations: ItemHttpRepresentation[],
authParams: KeyParamsData,
): Promise<string> {
const fileName = uuid.v4()
await (this.s3Client as S3Client).send(
@ -87,7 +92,7 @@ export class S3ItemBackupService implements ItemBackupServiceInterface {
Bucket: this.s3BackupBucketName,
Key: fileName,
Body: JSON.stringify({
items: itemProjections,
items: itemRepresentations,
auth_params: authParams,
}),
}),

View file

@ -0,0 +1,118 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'
@Entity({ name: 'items' })
@Index('index_items_on_user_uuid_and_content_type', ['userUuid', 'contentType'])
@Index('user_uuid_and_updated_at_timestamp_and_created_at_timestamp', [
'userUuid',
'updatedAtTimestamp',
'createdAtTimestamp',
])
@Index('user_uuid_and_deleted', ['userUuid', 'deleted'])
export class TypeORMItem {
@PrimaryGeneratedColumn('uuid')
declare uuid: string
@Column({
type: 'varchar',
name: 'duplicate_of',
length: 36,
nullable: true,
})
declare duplicateOf: string | null
@Column({
type: 'varchar',
name: 'items_key_id',
length: 255,
nullable: true,
})
declare itemsKeyId: string | null
@Column({
type: 'text',
nullable: true,
})
declare content: string | null
@Column({
name: 'content_type',
type: 'varchar',
length: 255,
nullable: true,
})
@Index('index_items_on_content_type')
declare contentType: string | null
@Column({
name: 'content_size',
type: 'int',
nullable: true,
})
declare contentSize: number | null
@Column({
name: 'enc_item_key',
type: 'text',
nullable: true,
})
declare encItemKey: string | null
@Column({
name: 'auth_hash',
type: 'varchar',
length: 255,
nullable: true,
})
declare authHash: string | null
@Column({
name: 'user_uuid',
length: 36,
})
@Index('index_items_on_user_uuid')
declare userUuid: string
@Column({
type: 'tinyint',
precision: 1,
nullable: true,
default: 0,
})
@Index('index_items_on_deleted')
declare deleted: boolean
@Column({
name: 'created_at',
type: 'datetime',
precision: 6,
})
declare createdAt: Date
@Column({
name: 'updated_at',
type: 'datetime',
precision: 6,
})
declare updatedAt: Date
@Column({
name: 'created_at_timestamp',
type: 'bigint',
})
declare createdAtTimestamp: number
@Column({
name: 'updated_at_timestamp',
type: 'bigint',
})
@Index('updated_at_timestamp')
declare updatedAtTimestamp: number
@Column({
name: 'updated_with_session',
type: 'varchar',
length: 36,
nullable: true,
})
declare updatedWithSession: string | null
}

View file

@ -1,19 +1,24 @@
import { ReadStream } from 'fs'
import { Repository, SelectQueryBuilder } from 'typeorm'
import { MapperInterface } from '@standardnotes/domain-core'
import { Item } from '../../Domain/Item/Item'
import { ItemQuery } from '../../Domain/Item/ItemQuery'
import { ItemRepositoryInterface } from '../../Domain/Item/ItemRepositoryInterface'
import { ReadStream } from 'fs'
import { ExtendedIntegrityPayload } from '../../Domain/Item/ExtendedIntegrityPayload'
import { TypeORMItem } from './TypeORMItem'
export class TypeORMItemRepository implements ItemRepositoryInterface {
constructor(private ormRepository: Repository<Item>) {}
constructor(private ormRepository: Repository<TypeORMItem>, private mapper: MapperInterface<Item, TypeORMItem>) {}
async save(item: Item): Promise<Item> {
return this.ormRepository.save(item)
async save(item: Item): Promise<void> {
const persistence = this.mapper.toProjection(item)
await this.ormRepository.save(persistence)
}
async remove(item: Item): Promise<Item> {
return this.ormRepository.remove(item)
async remove(item: Item): Promise<void> {
await this.ormRepository.remove(this.mapper.toProjection(item))
}
async updateContentSize(itemUuid: string, contentSize: number): Promise<void> {
@ -51,12 +56,18 @@ export class TypeORMItemRepository implements ItemRepositoryInterface {
}
async findByUuid(uuid: string): Promise<Item | null> {
return this.ormRepository
const persistence = await this.ormRepository
.createQueryBuilder('item')
.where('item.uuid = :uuid', {
uuid,
})
.getOne()
if (persistence === null) {
return null
}
return this.mapper.toDomain(persistence)
}
async findDatesForComputingIntegrityHash(userUuid: string): Promise<Array<{ updated_at_timestamp: number }>> {
@ -84,17 +95,25 @@ export class TypeORMItemRepository implements ItemRepositoryInterface {
}
async findByUuidAndUserUuid(uuid: string, userUuid: string): Promise<Item | null> {
return this.ormRepository
const persistence = await this.ormRepository
.createQueryBuilder('item')
.where('item.uuid = :uuid AND item.user_uuid = :userUuid', {
uuid,
userUuid,
})
.getOne()
if (persistence === null) {
return null
}
return this.mapper.toDomain(persistence)
}
async findAll(query: ItemQuery): Promise<Item[]> {
return this.createFindAllQueryBuilder(query).getMany()
const persistence = await this.createFindAllQueryBuilder(query).getMany()
return persistence.map((p) => this.mapper.toDomain(p))
}
async findAllRaw<T>(query: ItemQuery): Promise<T[]> {
@ -126,7 +145,7 @@ export class TypeORMItemRepository implements ItemRepositoryInterface {
.execute()
}
private createFindAllQueryBuilder(query: ItemQuery): SelectQueryBuilder<Item> {
private createFindAllQueryBuilder(query: ItemQuery): SelectQueryBuilder<TypeORMItem> {
const queryBuilder = this.ormRepository.createQueryBuilder('item')
if (query.sortBy !== undefined && query.sortOrder !== undefined) {

View file

@ -0,0 +1,32 @@
import { MapperInterface } from '@standardnotes/domain-core'
import { TimerInterface } from '@standardnotes/time'
import { Item } from '../../Domain/Item/Item'
import { ItemBackupRepresentation } from './ItemBackupRepresentation'
export class ItemBackupMapper implements MapperInterface<Item, ItemBackupRepresentation> {
constructor(private timer: TimerInterface) {}
toDomain(_projection: ItemBackupRepresentation): Item {
throw new Error('Mapping from http representation to domain is not implemented.')
}
toProjection(domain: Item): ItemBackupRepresentation {
return {
uuid: domain.id.toString(),
items_key_id: domain.props.itemsKeyId,
duplicate_of: domain.props.duplicateOf ? domain.props.duplicateOf.value : null,
enc_item_key: domain.props.encItemKey,
content: domain.props.content,
content_type: domain.props.contentType.value as string,
auth_hash: domain.props.authHash,
deleted: !!domain.props.deleted,
created_at: this.timer.convertMicrosecondsToStringDate(domain.props.timestamps.createdAt),
created_at_timestamp: domain.props.timestamps.createdAt,
updated_at: this.timer.convertMicrosecondsToStringDate(domain.props.timestamps.updatedAt),
updated_at_timestamp: domain.props.timestamps.updatedAt,
updated_with_session: domain.props.updatedWithSession ? domain.props.updatedWithSession.value : null,
user_uuid: domain.props.userUuid.value,
}
}
}

View file

@ -0,0 +1,16 @@
export interface ItemBackupRepresentation {
uuid: string
items_key_id: string | null
duplicate_of: string | null
enc_item_key: string | null
content: string | null
content_type: string
auth_hash: string | null
deleted: boolean
created_at: string
created_at_timestamp: number
updated_at: string
updated_at_timestamp: number
updated_with_session: string | null
user_uuid: string
}

View file

@ -0,0 +1,27 @@
import { MapperInterface } from '@standardnotes/domain-core'
import { Item } from '../../Domain/Item/Item'
import { ItemConflictHttpRepresentation } from './ItemConflictHttpRepresentation'
import { ItemConflict } from '../../Domain/Item/ItemConflict'
import { ItemHttpRepresentation } from './ItemHttpRepresentation'
export class ItemConflictHttpMapper implements MapperInterface<ItemConflict, ItemConflictHttpRepresentation> {
constructor(private mapper: MapperInterface<Item, ItemHttpRepresentation>) {}
toDomain(_projection: ItemConflictHttpRepresentation): ItemConflict {
throw new Error('Mapping from http representation to domain is not implemented.')
}
toProjection(domain: ItemConflict): ItemConflictHttpRepresentation {
const representation: ItemConflictHttpRepresentation = {
unsaved_item: domain.unsavedItem,
type: domain.type,
}
if (domain.serverItem) {
representation.server_item = this.mapper.toProjection(domain.serverItem)
}
return representation
}
}

View file

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

View file

@ -0,0 +1,31 @@
import { MapperInterface } from '@standardnotes/domain-core'
import { TimerInterface } from '@standardnotes/time'
import { Item } from '../../Domain/Item/Item'
import { ItemHttpRepresentation } from './ItemHttpRepresentation'
export class ItemHttpMapper implements MapperInterface<Item, ItemHttpRepresentation> {
constructor(private timer: TimerInterface) {}
toDomain(_projection: ItemHttpRepresentation): Item {
throw new Error('Mapping from http representation to domain is not implemented.')
}
toProjection(domain: Item): ItemHttpRepresentation {
return {
uuid: domain.id.toString(),
items_key_id: domain.props.itemsKeyId,
duplicate_of: domain.props.duplicateOf ? domain.props.duplicateOf.value : null,
enc_item_key: domain.props.encItemKey,
content: domain.props.content,
content_type: domain.props.contentType.value as string,
auth_hash: domain.props.authHash,
deleted: !!domain.props.deleted,
created_at: this.timer.convertMicrosecondsToStringDate(domain.props.timestamps.createdAt),
created_at_timestamp: domain.props.timestamps.createdAt,
updated_at: this.timer.convertMicrosecondsToStringDate(domain.props.timestamps.updatedAt),
updated_at_timestamp: domain.props.timestamps.updatedAt,
updated_with_session: domain.props.updatedWithSession ? domain.props.updatedWithSession.value : null,
}
}
}

View file

@ -1,4 +1,4 @@
export type ItemProjection = {
export interface ItemHttpRepresentation {
uuid: string
items_key_id: string | null
duplicate_of: string | null

View file

@ -0,0 +1,27 @@
import { MapperInterface } from '@standardnotes/domain-core'
import { TimerInterface } from '@standardnotes/time'
import { Item } from '../../Domain/Item/Item'
import { SavedItemHttpRepresentation } from './SavedItemHttpRepresentation'
export class SavedItemHttpMapper implements MapperInterface<Item, SavedItemHttpRepresentation> {
constructor(private timer: TimerInterface) {}
toDomain(_projection: SavedItemHttpRepresentation): Item {
throw new Error('Mapping from http representation to domain is not implemented.')
}
toProjection(domain: Item): SavedItemHttpRepresentation {
return {
uuid: domain.id.toString(),
duplicate_of: domain.props.duplicateOf ? domain.props.duplicateOf.value : null,
content_type: domain.props.contentType.value as string,
auth_hash: domain.props.authHash,
deleted: !!domain.props.deleted,
created_at: this.timer.convertMicrosecondsToStringDate(domain.props.timestamps.createdAt),
created_at_timestamp: domain.props.timestamps.createdAt,
updated_at: this.timer.convertMicrosecondsToStringDate(domain.props.timestamps.updatedAt),
updated_at_timestamp: domain.props.timestamps.updatedAt,
}
}
}

View file

@ -1,4 +1,4 @@
export type SavedItemProjection = {
export interface SavedItemHttpRepresentation {
uuid: string
duplicate_of: string | null
content_type: string

View file

@ -0,0 +1,96 @@
import { Timestamps, MapperInterface, UniqueEntityId, Uuid, ContentType, Dates } from '@standardnotes/domain-core'
import { Item } from '../../Domain/Item/Item'
import { TypeORMItem } from '../../Infra/TypeORM/TypeORMItem'
export class ItemPersistenceMapper implements MapperInterface<Item, TypeORMItem> {
toDomain(projection: TypeORMItem): Item {
let duplicateOf = null
if (projection.duplicateOf) {
const duplicateOfOrError = Uuid.create(projection.duplicateOf)
if (duplicateOfOrError.isFailed()) {
throw new Error(`Failed to create item from projection: ${duplicateOfOrError.getError()}`)
}
duplicateOf = duplicateOfOrError.getValue()
}
const contentTypeOrError = ContentType.create(projection.contentType)
if (contentTypeOrError.isFailed()) {
throw new Error(`Failed to create item from projection: ${contentTypeOrError.getError()}`)
}
const contentType = contentTypeOrError.getValue()
const userUuidOrError = Uuid.create(projection.userUuid)
if (userUuidOrError.isFailed()) {
throw new Error(`Failed to create item from projection: ${userUuidOrError.getError()}`)
}
const userUuid = userUuidOrError.getValue()
const datesOrError = Dates.create(projection.createdAt, projection.updatedAt)
if (datesOrError.isFailed()) {
throw new Error(`Failed to create item from projection: ${datesOrError.getError()}`)
}
const dates = datesOrError.getValue()
const timestampsOrError = Timestamps.create(projection.createdAtTimestamp, projection.updatedAtTimestamp)
if (timestampsOrError.isFailed()) {
throw new Error(`Failed to create item from projection: ${timestampsOrError.getError()}`)
}
const timestamps = timestampsOrError.getValue()
let updatedWithSession = null
if (projection.updatedWithSession) {
const updatedWithSessionOrError = Uuid.create(projection.updatedWithSession)
if (updatedWithSessionOrError.isFailed()) {
throw new Error(`Failed to create item from projection: ${updatedWithSessionOrError.getError()}`)
}
updatedWithSession = updatedWithSessionOrError.getValue()
}
const itemOrError = Item.create(
{
duplicateOf,
itemsKeyId: projection.itemsKeyId,
content: projection.content,
contentType,
contentSize: projection.contentSize ?? undefined,
encItemKey: projection.encItemKey,
authHash: projection.authHash,
userUuid,
deleted: projection.deleted,
dates,
timestamps,
updatedWithSession,
},
new UniqueEntityId(projection.uuid),
)
if (itemOrError.isFailed()) {
throw new Error(`Failed to create item from projection: ${itemOrError.getError()}`)
}
return itemOrError.getValue()
}
toProjection(domain: Item): TypeORMItem {
const typeorm = new TypeORMItem()
typeorm.uuid = domain.id.toString()
typeorm.duplicateOf = domain.props.duplicateOf ? domain.props.duplicateOf.value : null
typeorm.itemsKeyId = domain.props.itemsKeyId
typeorm.content = domain.props.content
typeorm.contentType = domain.props.contentType.value
typeorm.contentSize = domain.props.contentSize ?? null
typeorm.encItemKey = domain.props.encItemKey
typeorm.authHash = domain.props.authHash
typeorm.userUuid = domain.props.userUuid.value
typeorm.deleted = domain.props.deleted
typeorm.createdAt = domain.props.dates.createdAt
typeorm.updatedAt = domain.props.dates.updatedAt
typeorm.createdAtTimestamp = domain.props.timestamps.createdAt
typeorm.updatedAtTimestamp = domain.props.timestamps.updatedAt
typeorm.updatedWithSession = domain.props.updatedWithSession ? domain.props.updatedWithSession.value : null
return typeorm
}
}

View file

@ -1,10 +0,0 @@
import { ConflictType } from '@standardnotes/responses'
import { ItemHash } from '../Domain/Item/ItemHash'
import { ItemProjection } from './ItemProjection'
export type ItemConflictProjection = {
server_item?: ItemProjection
unsaved_item?: ItemHash
type: ConflictType
}

View file

@ -1,75 +0,0 @@
import 'reflect-metadata'
import { ProjectorInterface } from './ProjectorInterface'
import { Item } from '../Domain/Item/Item'
import { ItemConflict } from '../Domain/Item/ItemConflict'
import { ItemConflictProjector } from './ItemConflictProjector'
import { ItemHash } from '../Domain/Item/ItemHash'
import { ItemProjection } from './ItemProjection'
import { ConflictType } from '@standardnotes/responses'
describe('ItemConflictProjector', () => {
let itemProjector: ProjectorInterface<Item, ItemProjection>
let itemProjection: ItemProjection
let itemConflict1: ItemConflict
let itemConflict2: ItemConflict
let item: Item
let itemHash: ItemHash
const createProjector = () => new ItemConflictProjector(itemProjector)
beforeEach(() => {
itemProjection = {} as jest.Mocked<ItemProjection>
itemProjector = {} as jest.Mocked<ProjectorInterface<Item, ItemProjection>>
itemProjector.projectFull = jest.fn().mockReturnValue(itemProjection)
item = {} as jest.Mocked<Item>
itemHash = {} as jest.Mocked<ItemHash>
itemConflict1 = {
serverItem: item,
type: ConflictType.ConflictingData,
}
itemConflict2 = {
unsavedItem: itemHash,
type: ConflictType.UuidConflict,
}
})
it('should create a full projection of a server item conflict', async () => {
expect(await createProjector().projectFull(itemConflict1)).toMatchObject({
server_item: itemProjection,
type: ConflictType.ConflictingData,
})
})
it('should create a full projection of an unsaved item conflict', async () => {
expect(await createProjector().projectFull(itemConflict2)).toMatchObject({
unsaved_item: itemHash,
type: 'uuid_conflict',
})
})
it('should throw error on custom projection', async () => {
let error = null
try {
await createProjector().projectCustom('test', itemConflict1)
} catch (e) {
error = e
}
expect((error as Error).message).toEqual('not implemented')
})
it('should throw error on simple projection', async () => {
let error = null
try {
await createProjector().projectSimple(itemConflict1)
} catch (e) {
error = e
}
expect((error as Error).message).toEqual('not implemented')
})
})

View file

@ -1,31 +0,0 @@
import { ProjectorInterface } from './ProjectorInterface'
import { Item } from '../Domain/Item/Item'
import { ItemConflict } from '../Domain/Item/ItemConflict'
import { ItemConflictProjection } from './ItemConflictProjection'
import { ItemProjection } from './ItemProjection'
export class ItemConflictProjector implements ProjectorInterface<ItemConflict, ItemConflictProjection> {
constructor(private itemProjector: ProjectorInterface<Item, ItemProjection>) {}
async projectSimple(_itemConflict: ItemConflict): Promise<ItemConflictProjection> {
throw Error('not implemented')
}
async projectCustom(_projectionType: string, _itemConflict: ItemConflict): Promise<ItemConflictProjection> {
throw Error('not implemented')
}
async projectFull(itemConflict: ItemConflict): Promise<ItemConflictProjection> {
const projection: ItemConflictProjection = {
unsaved_item: itemConflict.unsavedItem,
type: itemConflict.type,
}
if (itemConflict.serverItem) {
projection.server_item = <ItemProjection>await this.itemProjector.projectFull(itemConflict.serverItem)
}
return projection
}
}

View file

@ -1,5 +0,0 @@
import { ItemProjection } from './ItemProjection'
export type ItemProjectionWithUser = ItemProjection & {
user_uuid: string
}

View file

@ -1,75 +0,0 @@
import 'reflect-metadata'
import { TimerInterface } from '@standardnotes/time'
import { Item } from '../Domain/Item/Item'
import { ItemProjector } from './ItemProjector'
import { ContentType } from '@standardnotes/common'
describe('ItemProjector', () => {
let item: Item
let timer: TimerInterface
const createProjector = () => new ItemProjector(timer)
beforeEach(() => {
timer = {} as jest.Mocked<TimerInterface>
timer.convertMicrosecondsToStringDate = jest.fn().mockReturnValue('2021-04-15T08:00:00.123456Z')
item = new Item()
item.uuid = '1-2-3'
item.itemsKeyId = '2-3-4'
item.duplicateOf = null
item.encItemKey = '3-4-5'
item.content = 'test'
item.contentType = ContentType.Note
item.authHash = 'asd'
item.deleted = false
item.createdAtTimestamp = 123
item.updatedAtTimestamp = 123
item.updatedWithSession = '7-6-5'
item.userUuid = 'u1-2-3'
})
it('should create a full projection of an item', async () => {
expect(await createProjector().projectFull(item)).toMatchObject({
uuid: '1-2-3',
items_key_id: '2-3-4',
duplicate_of: null,
enc_item_key: '3-4-5',
content: 'test',
content_type: 'Note',
auth_hash: 'asd',
deleted: false,
created_at: '2021-04-15T08:00:00.123456Z',
updated_at: '2021-04-15T08:00:00.123456Z',
updated_with_session: '7-6-5',
})
})
it('should create a custom projection of an item', async () => {
expect(await createProjector().projectCustom('dump', item)).toMatchObject({
uuid: '1-2-3',
items_key_id: '2-3-4',
duplicate_of: null,
enc_item_key: '3-4-5',
content: 'test',
content_type: 'Note',
auth_hash: 'asd',
deleted: false,
created_at: '2021-04-15T08:00:00.123456Z',
updated_at: '2021-04-15T08:00:00.123456Z',
updated_with_session: '7-6-5',
user_uuid: 'u1-2-3',
})
})
it('should throw error on simple projection', async () => {
let error = null
try {
await createProjector().projectSimple(item)
} catch (e) {
error = e
}
expect((error as Error).message).toEqual('not implemented')
})
})

View file

@ -1,41 +0,0 @@
import { TimerInterface } from '@standardnotes/time'
import { ProjectorInterface } from './ProjectorInterface'
import { Item } from '../Domain/Item/Item'
import { ItemProjection } from './ItemProjection'
import { ItemProjectionWithUser } from './ItemProjectionWithUser'
export class ItemProjector implements ProjectorInterface<Item, ItemProjection> {
constructor(private timer: TimerInterface) {}
async projectSimple(_item: Item): Promise<ItemProjection> {
throw Error('not implemented')
}
async projectCustom(_projectionType: string, item: Item): Promise<ItemProjectionWithUser> {
const fullProjection = await this.projectFull(item)
return {
...fullProjection,
user_uuid: item.userUuid,
}
}
async projectFull(item: Item): Promise<ItemProjection> {
return {
uuid: item.uuid,
items_key_id: item.itemsKeyId,
duplicate_of: item.duplicateOf,
enc_item_key: item.encItemKey,
content: item.content,
content_type: item.contentType as string,
auth_hash: item.authHash,
deleted: !!item.deleted,
created_at: this.timer.convertMicrosecondsToStringDate(item.createdAtTimestamp),
created_at_timestamp: item.createdAtTimestamp,
updated_at: this.timer.convertMicrosecondsToStringDate(item.updatedAtTimestamp),
updated_at_timestamp: item.updatedAtTimestamp,
updated_with_session: item.updatedWithSession,
}
}
}

View file

@ -1,5 +0,0 @@
export interface ProjectorInterface<T, E> {
projectSimple(object: T): Promise<Partial<E>>
projectFull(object: T): Promise<E>
projectCustom(projectionType: string, object: T, ...args: any[]): Promise<E>
}

View file

@ -1,64 +0,0 @@
import 'reflect-metadata'
import { TimerInterface } from '@standardnotes/time'
import { Item } from '../Domain/Item/Item'
import { SavedItemProjector } from './SavedItemProjector'
import { ContentType } from '@standardnotes/common'
describe('SavedItemProjector', () => {
let item: Item
let timer: TimerInterface
const createProjector = () => new SavedItemProjector(timer)
beforeEach(() => {
timer = {} as jest.Mocked<TimerInterface>
timer.convertMicrosecondsToStringDate = jest.fn().mockReturnValue('2021-04-15T08:00:00.123456Z')
item = new Item()
item.uuid = '1-2-3'
item.itemsKeyId = '2-3-4'
item.duplicateOf = null
item.encItemKey = '3-4-5'
item.content = 'test'
item.contentType = ContentType.Note
item.authHash = 'asd'
item.deleted = false
item.createdAtTimestamp = 123
item.updatedAtTimestamp = 123
})
it('should create a full projection of an item', async () => {
expect(await createProjector().projectFull(item)).toEqual({
uuid: '1-2-3',
duplicate_of: null,
content_type: 'Note',
auth_hash: 'asd',
deleted: false,
created_at: '2021-04-15T08:00:00.123456Z',
created_at_timestamp: 123,
updated_at: '2021-04-15T08:00:00.123456Z',
updated_at_timestamp: 123,
})
})
it('should throw error on custom projection', async () => {
let error = null
try {
await createProjector().projectCustom('test', item)
} catch (e) {
error = e
}
expect((error as Error).message).toEqual('not implemented')
})
it('should throw error on simple projection', async () => {
let error = null
try {
await createProjector().projectSimple(item)
} catch (e) {
error = e
}
expect((error as Error).message).toEqual('not implemented')
})
})

Some files were not shown because too many files have changed in this diff Show more