Compare commits

..

21 Commits

Author SHA1 Message Date
standardci
883df939dd chore(release): publish new version
- @standardnotes/api-gateway@1.74.10
 - @standardnotes/auth-server@1.141.10
 - @standardnotes/home-server@1.15.51
 - @standardnotes/revisions-server@1.33.20
 - @standardnotes/syncing-server@1.95.17
2023-09-13 08:40:17 +00:00
Karol Sójko
c7807d0f9e fix: adjust transition timestamps to be universal 2023-09-13 10:25:02 +02:00
standardci
fc90343aaa chore(release): publish new version
- @standardnotes/home-server@1.15.50
 - @standardnotes/revisions-server@1.33.19
 - @standardnotes/syncing-server@1.95.16
2023-09-13 08:17:45 +00:00
Karol Sójko
fbcb45c3a2 fix: include handling updated items in revisions in secondary 2023-09-13 10:00:02 +02:00
standardci
179d8eaaa1 chore(release): publish new version
- @standardnotes/analytics@2.26.12
 - @standardnotes/api-gateway@1.74.9
 - @standardnotes/auth-server@1.141.9
 - @standardnotes/domain-events-infra@1.12.28
 - @standardnotes/domain-events@2.125.4
 - @standardnotes/event-store@1.11.40
 - @standardnotes/files-server@1.22.19
 - @standardnotes/home-server@1.15.49
 - @standardnotes/revisions-server@1.33.18
 - @standardnotes/scheduler-server@1.20.44
 - @standardnotes/syncing-server@1.95.15
 - @standardnotes/websockets-server@1.10.41
2023-09-13 07:47:36 +00:00
Karol Sójko
38685c1861 fix: display transition progress in logs 2023-09-13 09:31:07 +02:00
standardci
cdf42fbe2d chore(release): publish new version
- @standardnotes/home-server@1.15.48
 - @standardnotes/revisions-server@1.33.17
 - @standardnotes/syncing-server@1.95.14
2023-09-13 06:39:14 +00:00
Karol Sójko
9be4c002b7 fix: setting status for already migrated users 2023-09-13 08:21:21 +02:00
standardci
a16c5307a0 chore(release): publish new version
- @standardnotes/home-server@1.15.47
 - @standardnotes/revisions-server@1.33.16
 - @standardnotes/syncing-server@1.95.13
2023-09-13 04:50:15 +00:00
Karol Sójko
d5536f5430 fix(syncing-server): case insensitive integrity check 2023-09-13 06:33:31 +02:00
Karol Sójko
b1d88b15be fix: cleanup only for 0 new items 2023-09-12 22:51:39 +02:00
Karol Sójko
ff78285e43 fix(syncing-server): add catch up timeout for secondary db 2023-09-12 22:24:48 +02:00
standardci
1a26221385 chore(release): publish new version
- @standardnotes/auth-server@1.141.8
 - @standardnotes/home-server@1.15.46
 - @standardnotes/revisions-server@1.33.15
 - @standardnotes/syncing-server@1.95.12
2023-09-12 20:05:02 +00:00
Karol Sójko
54113abe2a fix: imports 2023-09-12 21:31:58 +02:00
Karol Sójko
afe385aed4 fix(auth): remove the transition role constraint 2023-09-12 21:28:48 +02:00
Karol Sójko
f055e52e06 fix(auth): add transition role only if the items transition has completed 2023-09-12 21:26:40 +02:00
Karol Sójko
fab5d18064 fix: sync between primary and secondary database with diff 2023-09-12 21:00:01 +02:00
standardci
a1e654a0d0 chore(release): publish new version
- @standardnotes/home-server@1.15.45
 - @standardnotes/revisions-server@1.33.14
2023-09-12 16:21:14 +00:00
Karol Sójko
aa835268ea fix(revisions): handle transitions with already existing data in secondary 2023-09-12 17:42:11 +02:00
standardci
74b4312928 chore(release): publish new version
- @standardnotes/home-server@1.15.44
 - @standardnotes/syncing-server@1.95.11
2023-09-12 15:17:12 +00:00
Karol Sójko
e91a832152 fix(syncing-server): binding 2023-09-12 16:41:39 +02:00
65 changed files with 662 additions and 1109 deletions

View File

@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [2.26.12](https://github.com/standardnotes/server/compare/@standardnotes/analytics@2.26.11...@standardnotes/analytics@2.26.12) (2023-09-13)
**Note:** Version bump only for package @standardnotes/analytics
## [2.26.11](https://github.com/standardnotes/server/compare/@standardnotes/analytics@2.26.10...@standardnotes/analytics@2.26.11) (2023-09-12)
**Note:** Version bump only for package @standardnotes/analytics

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/analytics",
"version": "2.26.11",
"version": "2.26.12",
"engines": {
"node": ">=18.0.0 <21.0.0"
},

View File

@@ -3,6 +3,16 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.74.10](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.74.9...@standardnotes/api-gateway@1.74.10) (2023-09-13)
### Bug Fixes
* adjust transition timestamps to be universal ([c7807d0](https://github.com/standardnotes/api-gateway/commit/c7807d0f9e69ce572c4c03ff606375d706f24d9f))
## [1.74.9](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.74.8...@standardnotes/api-gateway@1.74.9) (2023-09-13)
**Note:** Version bump only for package @standardnotes/api-gateway
## [1.74.8](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.74.7...@standardnotes/api-gateway@1.74.8) (2023-09-12)
**Note:** Version bump only for package @standardnotes/api-gateway

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/api-gateway",
"version": "1.74.8",
"version": "1.74.10",
"engines": {
"node": ">=18.0.0 <21.0.0"
},

View File

@@ -80,15 +80,6 @@ export class UsersController extends BaseHttpController {
)
}
@httpGet('/transition-status', TYPES.ApiGateway_RequiredCrossServiceTokenMiddleware)
async getTransitionStatus(request: Request, response: Response): Promise<void> {
await this.httpService.callAuthServer(
request,
response,
this.endpointResolver.resolveEndpointOrMethodIdentifier('GET', 'users/transition-status'),
)
}
@httpGet('/:userId/params', TYPES.ApiGateway_RequiredCrossServiceTokenMiddleware)
async getKeyParams(request: Request, response: Response): Promise<void> {
await this.httpService.callAuthServer(

View File

@@ -43,7 +43,6 @@ export class EndpointResolver implements EndpointResolverInterface {
['[PATCH]:users/:userId', 'auth.users.update'],
['[PUT]:users/:userUuid/attributes/credentials', 'auth.users.updateCredentials'],
['[GET]:users/params', 'auth.users.getKeyParams'],
['[GET]:users/transition-status', 'auth.users.transition-status'],
['[DELETE]:users/:userUuid', 'auth.users.delete'],
['[POST]:listed', 'auth.users.createListedAccount'],
['[POST]:auth', 'auth.users.register'],
@@ -59,13 +58,11 @@ export class EndpointResolver implements EndpointResolverInterface {
// Syncing Server
['[POST]:items/sync', 'sync.items.sync'],
['[POST]:items/check-integrity', 'sync.items.check_integrity'],
['[POST]:items/transition', 'sync.items.transition'],
['[GET]:items/:uuid', 'sync.items.get_item'],
// Revisions Controller V2
['[GET]:items/:itemUuid/revisions', 'revisions.revisions.getRevisions'],
['[GET]:items/:itemUuid/revisions/:id', 'revisions.revisions.getRevision'],
['[DELETE]:items/:itemUuid/revisions/:id', 'revisions.revisions.deleteRevision'],
['[POST]:revisions/transition', 'revisions.revisions.transition'],
// Messages Controller
['[GET]:messages/', 'sync.messages.get-received'],
['[GET]:messages/outbound', 'sync.messages.get-sent'],

View File

@@ -3,6 +3,26 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.141.10](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.141.9...@standardnotes/auth-server@1.141.10) (2023-09-13)
### Bug Fixes
* adjust transition timestamps to be universal ([c7807d0](https://github.com/standardnotes/server/commit/c7807d0f9e69ce572c4c03ff606375d706f24d9f))
## [1.141.9](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.141.8...@standardnotes/auth-server@1.141.9) (2023-09-13)
### Bug Fixes
* display transition progress in logs ([38685c1](https://github.com/standardnotes/server/commit/38685c1861b13e398dd96aa39f2cf1aece2090fb))
## [1.141.8](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.141.7...@standardnotes/auth-server@1.141.8) (2023-09-12)
### Bug Fixes
* **auth:** add transition role only if the items transition has completed ([f055e52](https://github.com/standardnotes/server/commit/f055e52e06b6e93501abd340dfce214d5363bc30))
* **auth:** remove the transition role constraint ([afe385a](https://github.com/standardnotes/server/commit/afe385aed4ba5ca53d8ef429ae4154f4ccf81419))
* imports ([54113ab](https://github.com/standardnotes/server/commit/54113abe2a961720a3561e5ff3a0069046ea8d25))
## [1.141.7](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.141.6...@standardnotes/auth-server@1.141.7) (2023-09-12)
### Bug Fixes

View File

@@ -10,7 +10,6 @@ import { Env } from '../src/Bootstrap/Env'
import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
import { DomainEventFactoryInterface } from '../src/Domain/Event/DomainEventFactoryInterface'
import { UserRepositoryInterface } from '../src/Domain/User/UserRepositoryInterface'
import { RoleName } from '@standardnotes/domain-core'
import { TransitionStatusRepositoryInterface } from '../src/Domain/Transition/TransitionStatusRepositoryInterface'
const inputArgs = process.argv.slice(2)
@@ -33,12 +32,6 @@ const requestTransition = async (
let usersTriggered = 0
for (const user of users) {
const roles = await user.roles
const userHasTransitionUserRole = roles.some((role) => role.name === RoleName.NAMES.TransitionUser) === true
if (userHasTransitionUserRole === true) {
continue
}
const transitionRequestedEvent = domainEventFactory.createTransitionRequestedEvent({
userUuid: user.uuid,
type: 'items',

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/auth-server",
"version": "1.141.7",
"version": "1.141.10",
"engines": {
"node": ">=18.0.0 <21.0.0"
},

View File

@@ -943,6 +943,7 @@ export class ContainerConfigLoader {
new UpdateTransitionStatus(
container.get<TransitionStatusRepositoryInterface>(TYPES.Auth_TransitionStatusRepository),
container.get<RoleServiceInterface>(TYPES.Auth_RoleService),
container.get<winston.Logger>(TYPES.Auth_Logger),
),
)
container
@@ -1275,7 +1276,6 @@ export class ContainerConfigLoader {
container.get<ClearLoginAttempts>(TYPES.Auth_ClearLoginAttempts),
container.get<IncreaseLoginAttempts>(TYPES.Auth_IncreaseLoginAttempts),
container.get<ChangeCredentials>(TYPES.Auth_ChangeCredentials),
container.get<GetTransitionStatus>(TYPES.Auth_GetTransitionStatus),
container.get<ControllerContainerInterface>(TYPES.Auth_ControllerContainer),
),
)

View File

@@ -44,7 +44,10 @@ export class DomainEventFactory implements DomainEventFactoryInterface {
},
origin: DomainEventService.Auth,
},
payload: dto,
payload: {
timestamp: this.timer.getTimestampInMicroseconds(),
...dto,
},
}
}

View File

@@ -13,6 +13,7 @@ export class TransitionStatusUpdatedEventHandler implements DomainEventHandlerIn
status: event.payload.status,
userUuid: event.payload.userUuid,
transitionType: event.payload.transitionType,
transitionTimestamp: event.payload.transitionTimestamp,
})
if (result.isFailed()) {

View File

@@ -3,17 +3,36 @@ import { RoleName, Uuid } from '@standardnotes/domain-core'
import { RoleServiceInterface } from '../../Role/RoleServiceInterface'
import { TransitionStatusRepositoryInterface } from '../../Transition/TransitionStatusRepositoryInterface'
import { UpdateTransitionStatus } from './UpdateTransitionStatus'
import { Logger } from 'winston'
describe('UpdateTransitionStatus', () => {
let transitionStatusRepository: TransitionStatusRepositoryInterface
let roleService: RoleServiceInterface
let logger: Logger
const createUseCase = () => new UpdateTransitionStatus(transitionStatusRepository, roleService)
const createUseCase = () => new UpdateTransitionStatus(transitionStatusRepository, roleService, logger)
beforeEach(() => {
logger = {} as jest.Mocked<Logger>
logger.info = jest.fn()
transitionStatusRepository = {} as jest.Mocked<TransitionStatusRepositoryInterface>
transitionStatusRepository.removeStatus = jest.fn()
transitionStatusRepository.updateStatus = jest.fn()
transitionStatusRepository.getStatuses = jest.fn().mockResolvedValue([
{
userUuid: '00000000-0000-0000-0000-000000000000',
status: 'STARTED',
},
{
userUuid: '00000000-0000-0000-0000-000000000001',
status: 'IN_PROGRESS',
},
{
userUuid: '00000000-0000-0000-0000-000000000002',
status: 'FAILED',
},
])
roleService = {} as jest.Mocked<RoleServiceInterface>
roleService.addRoleToUser = jest.fn()
@@ -26,6 +45,7 @@ describe('UpdateTransitionStatus', () => {
userUuid: '00000000-0000-0000-0000-000000000000',
status: 'FINISHED',
transitionType: 'items',
transitionTimestamp: 123,
})
expect(result.isFailed()).toBeFalsy()
@@ -39,6 +59,24 @@ describe('UpdateTransitionStatus', () => {
)
})
it('should remove transition status', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
status: 'FINISHED',
transitionType: 'revisions',
transitionTimestamp: 123,
})
expect(result.isFailed()).toBeFalsy()
expect(transitionStatusRepository.removeStatus).toHaveBeenCalledWith(
'00000000-0000-0000-0000-000000000000',
'revisions',
)
expect(roleService.addRoleToUser).not.toHaveBeenCalled()
})
it('should update transition status', async () => {
const useCase = createUseCase()
@@ -46,6 +84,7 @@ describe('UpdateTransitionStatus', () => {
userUuid: '00000000-0000-0000-0000-000000000000',
status: 'STARTED',
transitionType: 'items',
transitionTimestamp: 123,
})
expect(result.isFailed()).toBeFalsy()
@@ -63,6 +102,7 @@ describe('UpdateTransitionStatus', () => {
userUuid: 'invalid',
status: 'STARTED',
transitionType: 'items',
transitionTimestamp: 123,
})
expect(result.isFailed()).toBeTruthy()

View File

@@ -2,11 +2,13 @@ import { Result, RoleName, UseCaseInterface, Uuid } from '@standardnotes/domain-
import { TransitionStatusRepositoryInterface } from '../../Transition/TransitionStatusRepositoryInterface'
import { UpdateTransitionStatusDTO } from './UpdateTransitionStatusDTO'
import { RoleServiceInterface } from '../../Role/RoleServiceInterface'
import { Logger } from 'winston'
export class UpdateTransitionStatus implements UseCaseInterface<void> {
constructor(
private transitionStatusRepository: TransitionStatusRepositoryInterface,
private roleService: RoleServiceInterface,
private logger: Logger,
) {}
async execute(dto: UpdateTransitionStatusDTO): Promise<Result<void>> {
@@ -16,15 +18,35 @@ export class UpdateTransitionStatus implements UseCaseInterface<void> {
}
const userUuid = userUuidOrError.getValue()
if (dto.status === 'FINISHED') {
await this.transitionStatusRepository.removeStatus(dto.userUuid, dto.transitionType)
await this.roleService.addRoleToUser(userUuid, RoleName.create(RoleName.NAMES.TransitionUser).getValue())
if (dto.status !== 'FINISHED') {
await this.transitionStatusRepository.updateStatus(dto.userUuid, dto.transitionType, dto.status)
return Result.ok()
}
await this.transitionStatusRepository.updateStatus(dto.userUuid, dto.transitionType, dto.status)
await this.transitionStatusRepository.removeStatus(dto.userUuid, dto.transitionType)
if (dto.transitionType === 'items') {
await this.roleService.addRoleToUser(userUuid, RoleName.create(RoleName.NAMES.TransitionUser).getValue())
}
const itemStatuses = await this.transitionStatusRepository.getStatuses('items')
const itemsStartedStatusesCount = itemStatuses.filter((status) => status.status === 'STARTED').length
const itemsInProgressStatusesCount = itemStatuses.filter((status) => status.status === 'IN_PROGRESS').length
const itemsFailedStatusesCount = itemStatuses.filter((status) => status.status === 'FAILED').length
this.logger.info(
`[TRANSITION ${dto.transitionTimestamp}] Items transition statuses: ${itemsStartedStatusesCount} started, ${itemsInProgressStatusesCount} in progress, ${itemsFailedStatusesCount} failed`,
)
const revisionStatuses = await this.transitionStatusRepository.getStatuses('revisions')
const revisionsStartedStatusesCount = revisionStatuses.filter((status) => status.status === 'STARTED').length
const revisionsInProgressStatusesCount = revisionStatuses.filter((status) => status.status === 'IN_PROGRESS').length
const revisionsFailedStatusesCount = revisionStatuses.filter((status) => status.status === 'FAILED').length
this.logger.info(
`[TRANSITION ${dto.transitionTimestamp}] Revisions transition statuses: ${revisionsStartedStatusesCount} started, ${revisionsInProgressStatusesCount} in progress, ${revisionsFailedStatusesCount} failed`,
)
return Result.ok()
}

View File

@@ -1,5 +1,6 @@
export interface UpdateTransitionStatusDTO {
userUuid: string
transitionType: 'items' | 'revisions'
transitionTimestamp: number
status: 'STARTED' | 'IN_PROGRESS' | 'FINISHED' | 'FAILED'
}

View File

@@ -14,7 +14,6 @@ import { IncreaseLoginAttempts } from '../../Domain/UseCase/IncreaseLoginAttempt
import { InviteToSharedSubscription } from '../../Domain/UseCase/InviteToSharedSubscription/InviteToSharedSubscription'
import { UpdateUser } from '../../Domain/UseCase/UpdateUser'
import { User } from '../../Domain/User/User'
import { GetTransitionStatus } from '../../Domain/UseCase/GetTransitionStatus/GetTransitionStatus'
describe('AnnotatedUsersController', () => {
let updateUser: UpdateUser
@@ -25,7 +24,6 @@ describe('AnnotatedUsersController', () => {
let increaseLoginAttempts: IncreaseLoginAttempts
let changeCredentials: ChangeCredentials
let inviteToSharedSubscription: InviteToSharedSubscription
let getTransitionStatus: GetTransitionStatus
let request: express.Request
let response: express.Response
@@ -40,7 +38,6 @@ describe('AnnotatedUsersController', () => {
clearLoginAttempts,
increaseLoginAttempts,
changeCredentials,
getTransitionStatus,
)
beforeEach(() => {
@@ -72,9 +69,6 @@ describe('AnnotatedUsersController', () => {
inviteToSharedSubscription = {} as jest.Mocked<InviteToSharedSubscription>
inviteToSharedSubscription.execute = jest.fn()
getTransitionStatus = {} as jest.Mocked<GetTransitionStatus>
getTransitionStatus.execute = jest.fn()
request = {
headers: {},
body: {},

View File

@@ -18,7 +18,6 @@ import { ClearLoginAttempts } from '../../Domain/UseCase/ClearLoginAttempts'
import { IncreaseLoginAttempts } from '../../Domain/UseCase/IncreaseLoginAttempts'
import { ChangeCredentials } from '../../Domain/UseCase/ChangeCredentials/ChangeCredentials'
import { BaseUsersController } from './Base/BaseUsersController'
import { GetTransitionStatus } from '../../Domain/UseCase/GetTransitionStatus/GetTransitionStatus'
@controller('/users')
export class AnnotatedUsersController extends BaseUsersController {
@@ -30,7 +29,6 @@ export class AnnotatedUsersController extends BaseUsersController {
@inject(TYPES.Auth_ClearLoginAttempts) override clearLoginAttempts: ClearLoginAttempts,
@inject(TYPES.Auth_IncreaseLoginAttempts) override increaseLoginAttempts: IncreaseLoginAttempts,
@inject(TYPES.Auth_ChangeCredentials) override changeCredentialsUseCase: ChangeCredentials,
@inject(TYPES.Auth_GetTransitionStatus) override getTransitionStatusUseCase: GetTransitionStatus,
) {
super(
updateUser,
@@ -40,7 +38,6 @@ export class AnnotatedUsersController extends BaseUsersController {
clearLoginAttempts,
increaseLoginAttempts,
changeCredentialsUseCase,
getTransitionStatusUseCase,
)
}
@@ -54,11 +51,6 @@ export class AnnotatedUsersController extends BaseUsersController {
return super.keyParams(request)
}
@httpGet('/transition-status', TYPES.Auth_RequiredCrossServiceTokenMiddleware)
override async transitionStatus(request: Request, response: Response): Promise<results.JsonResult> {
return super.transitionStatus(request, response)
}
@httpDelete('/:userUuid', TYPES.Auth_RequiredCrossServiceTokenMiddleware)
override async deleteAccount(request: Request, response: Response): Promise<results.JsonResult> {
return super.deleteAccount(request, response)

View File

@@ -10,7 +10,6 @@ import { GetUserSubscription } from '../../../Domain/UseCase/GetUserSubscription
import { IncreaseLoginAttempts } from '../../../Domain/UseCase/IncreaseLoginAttempts'
import { UpdateUser } from '../../../Domain/UseCase/UpdateUser'
import { ErrorTag } from '@standardnotes/responses'
import { GetTransitionStatus } from '../../../Domain/UseCase/GetTransitionStatus/GetTransitionStatus'
export class BaseUsersController extends BaseHttpController {
constructor(
@@ -21,7 +20,6 @@ export class BaseUsersController extends BaseHttpController {
protected clearLoginAttempts: ClearLoginAttempts,
protected increaseLoginAttempts: IncreaseLoginAttempts,
protected changeCredentialsUseCase: ChangeCredentials,
protected getTransitionStatusUseCase: GetTransitionStatus,
private controllerContainer?: ControllerContainerInterface,
) {
super()
@@ -32,7 +30,6 @@ export class BaseUsersController extends BaseHttpController {
this.controllerContainer.register('auth.users.getSubscription', this.getSubscription.bind(this))
this.controllerContainer.register('auth.users.updateCredentials', this.changeCredentials.bind(this))
this.controllerContainer.register('auth.users.delete', this.deleteAccount.bind(this))
this.controllerContainer.register('auth.users.transition-status', this.transitionStatus.bind(this))
}
}
@@ -106,30 +103,6 @@ export class BaseUsersController extends BaseHttpController {
return this.json(result.keyParams)
}
async transitionStatus(_request: Request, response: Response): Promise<results.JsonResult> {
const result = await this.getTransitionStatusUseCase.execute({
userUuid: response.locals.user.uuid,
transitionType: 'items',
})
if (result.isFailed()) {
return this.json(
{
error: {
message: result.getError(),
},
},
400,
)
}
response.setHeader('x-invalidate-cache', response.locals.user.uuid)
return this.json({
status: result.getValue(),
})
}
async deleteAccount(request: Request, response: Response): Promise<results.JsonResult> {
if (request.params.userUuid !== response.locals.user.uuid) {
return this.json(

View File

@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.12.28](https://github.com/standardnotes/server/compare/@standardnotes/domain-events-infra@1.12.27...@standardnotes/domain-events-infra@1.12.28) (2023-09-13)
**Note:** Version bump only for package @standardnotes/domain-events-infra
## [1.12.27](https://github.com/standardnotes/server/compare/@standardnotes/domain-events-infra@1.12.26...@standardnotes/domain-events-infra@1.12.27) (2023-09-12)
**Note:** Version bump only for package @standardnotes/domain-events-infra

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/domain-events-infra",
"version": "1.12.27",
"version": "1.12.28",
"engines": {
"node": ">=18.0.0 <21.0.0"
},

View File

@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [2.125.4](https://github.com/standardnotes/server/compare/@standardnotes/domain-events@2.125.3...@standardnotes/domain-events@2.125.4) (2023-09-13)
### Bug Fixes
* display transition progress in logs ([38685c1](https://github.com/standardnotes/server/commit/38685c1861b13e398dd96aa39f2cf1aece2090fb))
## [2.125.3](https://github.com/standardnotes/server/compare/@standardnotes/domain-events@2.125.2...@standardnotes/domain-events@2.125.3) (2023-09-12)
### Bug Fixes

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/domain-events",
"version": "2.125.3",
"version": "2.125.4",
"engines": {
"node": ">=18.0.0 <21.0.0"
},

View File

@@ -1,4 +1,5 @@
export interface TransitionRequestedEventPayload {
userUuid: string
type: 'items' | 'revisions'
timestamp: number
}

View File

@@ -1,5 +1,6 @@
export interface TransitionStatusUpdatedEventPayload {
userUuid: string
transitionType: 'items' | 'revisions'
transitionTimestamp: number
status: 'STARTED' | 'IN_PROGRESS' | 'FINISHED' | 'FAILED'
}

View File

@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.11.40](https://github.com/standardnotes/server/compare/@standardnotes/event-store@1.11.39...@standardnotes/event-store@1.11.40) (2023-09-13)
**Note:** Version bump only for package @standardnotes/event-store
## [1.11.39](https://github.com/standardnotes/server/compare/@standardnotes/event-store@1.11.38...@standardnotes/event-store@1.11.39) (2023-09-12)
**Note:** Version bump only for package @standardnotes/event-store

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/event-store",
"version": "1.11.39",
"version": "1.11.40",
"description": "Event Store Service",
"private": true,
"main": "dist/src/index.js",

View File

@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.22.19](https://github.com/standardnotes/files/compare/@standardnotes/files-server@1.22.18...@standardnotes/files-server@1.22.19) (2023-09-13)
**Note:** Version bump only for package @standardnotes/files-server
## [1.22.18](https://github.com/standardnotes/files/compare/@standardnotes/files-server@1.22.17...@standardnotes/files-server@1.22.18) (2023-09-12)
**Note:** Version bump only for package @standardnotes/files-server

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/files-server",
"version": "1.22.18",
"version": "1.22.19",
"engines": {
"node": ">=18.0.0 <21.0.0"
},

View File

@@ -3,6 +3,38 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.15.51](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.15.50...@standardnotes/home-server@1.15.51) (2023-09-13)
**Note:** Version bump only for package @standardnotes/home-server
## [1.15.50](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.15.49...@standardnotes/home-server@1.15.50) (2023-09-13)
**Note:** Version bump only for package @standardnotes/home-server
## [1.15.49](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.15.48...@standardnotes/home-server@1.15.49) (2023-09-13)
**Note:** Version bump only for package @standardnotes/home-server
## [1.15.48](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.15.47...@standardnotes/home-server@1.15.48) (2023-09-13)
**Note:** Version bump only for package @standardnotes/home-server
## [1.15.47](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.15.46...@standardnotes/home-server@1.15.47) (2023-09-13)
**Note:** Version bump only for package @standardnotes/home-server
## [1.15.46](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.15.45...@standardnotes/home-server@1.15.46) (2023-09-12)
**Note:** Version bump only for package @standardnotes/home-server
## [1.15.45](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.15.44...@standardnotes/home-server@1.15.45) (2023-09-12)
**Note:** Version bump only for package @standardnotes/home-server
## [1.15.44](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.15.43...@standardnotes/home-server@1.15.44) (2023-09-12)
**Note:** Version bump only for package @standardnotes/home-server
## [1.15.43](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.15.42...@standardnotes/home-server@1.15.43) (2023-09-12)
**Note:** Version bump only for package @standardnotes/home-server

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/home-server",
"version": "1.15.43",
"version": "1.15.51",
"engines": {
"node": ">=18.0.0 <21.0.0"
},

View File

@@ -3,6 +3,48 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.33.20](https://github.com/standardnotes/server/compare/@standardnotes/revisions-server@1.33.19...@standardnotes/revisions-server@1.33.20) (2023-09-13)
### Bug Fixes
* adjust transition timestamps to be universal ([c7807d0](https://github.com/standardnotes/server/commit/c7807d0f9e69ce572c4c03ff606375d706f24d9f))
## [1.33.19](https://github.com/standardnotes/server/compare/@standardnotes/revisions-server@1.33.18...@standardnotes/revisions-server@1.33.19) (2023-09-13)
### Bug Fixes
* include handling updated items in revisions in secondary ([fbcb45c](https://github.com/standardnotes/server/commit/fbcb45c3a23fde09702fae7bfcb409bdbb610191))
## [1.33.18](https://github.com/standardnotes/server/compare/@standardnotes/revisions-server@1.33.17...@standardnotes/revisions-server@1.33.18) (2023-09-13)
### Bug Fixes
* display transition progress in logs ([38685c1](https://github.com/standardnotes/server/commit/38685c1861b13e398dd96aa39f2cf1aece2090fb))
## [1.33.17](https://github.com/standardnotes/server/compare/@standardnotes/revisions-server@1.33.16...@standardnotes/revisions-server@1.33.17) (2023-09-13)
### Bug Fixes
* setting status for already migrated users ([9be4c00](https://github.com/standardnotes/server/commit/9be4c002b755fea057489b6077b297162223aefe))
## [1.33.16](https://github.com/standardnotes/server/compare/@standardnotes/revisions-server@1.33.15...@standardnotes/revisions-server@1.33.16) (2023-09-13)
### Bug Fixes
* cleanup only for 0 new items ([b1d88b1](https://github.com/standardnotes/server/commit/b1d88b15be78a48224963e337a222fb675ed2692))
## [1.33.15](https://github.com/standardnotes/server/compare/@standardnotes/revisions-server@1.33.14...@standardnotes/revisions-server@1.33.15) (2023-09-12)
### Bug Fixes
* sync between primary and secondary database with diff ([fab5d18](https://github.com/standardnotes/server/commit/fab5d180645e0a6fa0c9c67205d44f27c8a65c8b))
## [1.33.14](https://github.com/standardnotes/server/compare/@standardnotes/revisions-server@1.33.13...@standardnotes/revisions-server@1.33.14) (2023-09-12)
### Bug Fixes
* **revisions:** handle transitions with already existing data in secondary ([aa83526](https://github.com/standardnotes/server/commit/aa835268ea80e3aa74907e449d189e8b2774a859))
## [1.33.13](https://github.com/standardnotes/server/compare/@standardnotes/revisions-server@1.33.12...@standardnotes/revisions-server@1.33.13) (2023-09-12)
### Bug Fixes

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/revisions-server",
"version": "1.33.13",
"version": "1.33.20",
"engines": {
"node": ">=18.0.0 <21.0.0"
},

View File

@@ -443,6 +443,7 @@ export class ContainerConfigLoader {
.bind<TransitionStatusUpdatedEventHandler>(TYPES.Revisions_TransitionStatusUpdatedEventHandler)
.toConstantValue(
new TransitionStatusUpdatedEventHandler(
container.get<RevisionRepositoryInterface>(TYPES.Revisions_SQLRevisionRepository),
container.get<TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser>(
TYPES.Revisions_TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser,
),
@@ -519,9 +520,6 @@ export class ContainerConfigLoader {
container.get<DeleteRevision>(TYPES.Revisions_DeleteRevision),
container.get<RevisionHttpMapper>(TYPES.Revisions_RevisionHttpMapper),
container.get<RevisionMetadataHttpMapper>(TYPES.Revisions_RevisionMetadataHttpMapper),
container.get<TriggerTransitionFromPrimaryToSecondaryDatabaseForUser>(
TYPES.Revisions_TriggerTransitionFromPrimaryToSecondaryDatabaseForUser,
),
container.get<ControllerContainerInterface>(TYPES.Revisions_ControllerContainer),
),
)

View File

@@ -9,6 +9,7 @@ export class DomainEventFactory implements DomainEventFactoryInterface {
createTransitionStatusUpdatedEvent(dto: {
userUuid: string
transitionType: 'items' | 'revisions'
transitionTimestamp: number
status: 'STARTED' | 'FAILED' | 'FINISHED'
}): TransitionStatusUpdatedEvent {
return {

View File

@@ -4,6 +4,7 @@ export interface DomainEventFactoryInterface {
createTransitionStatusUpdatedEvent(dto: {
userUuid: string
transitionType: 'items' | 'revisions'
transitionTimestamp: number
status: 'STARTED' | 'IN_PROGRESS' | 'FAILED' | 'FINISHED'
}): TransitionStatusUpdatedEvent
}

View File

@@ -18,6 +18,7 @@ export class TransitionRequestedEventHandler implements DomainEventHandlerInterf
const result = await this.triggerTransitionFromPrimaryToSecondaryDatabaseForUser.execute({
userUuid: event.payload.userUuid,
transitionTimestamp: event.payload.timestamp,
})
if (result.isFailed()) {

View File

@@ -6,9 +6,12 @@ import {
import { Logger } from 'winston'
import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface'
import { TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser } from '../UseCase/Transition/TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser/TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser'
import { Uuid } from '@standardnotes/domain-core'
import { RevisionRepositoryInterface } from '../Revision/RevisionRepositoryInterface'
export class TransitionStatusUpdatedEventHandler implements DomainEventHandlerInterface {
constructor(
private primaryRevisionsRepository: RevisionRepositoryInterface,
private transitionRevisionsFromPrimaryToSecondaryDatabaseForUser: TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser,
private domainEventPublisher: DomainEventPublisherInterface,
private domainEventFactory: DomainEventFactoryInterface,
@@ -22,6 +25,7 @@ export class TransitionStatusUpdatedEventHandler implements DomainEventHandlerIn
userUuid: event.payload.userUuid,
status: 'STARTED',
transitionType: 'revisions',
transitionTimestamp: event.payload.transitionTimestamp,
}),
)
@@ -29,11 +33,42 @@ export class TransitionStatusUpdatedEventHandler implements DomainEventHandlerIn
}
if (event.payload.status === 'STARTED' && event.payload.transitionType === 'revisions') {
const userUuidOrError = Uuid.create(event.payload.userUuid)
if (userUuidOrError.isFailed()) {
this.logger.error(
`Failed to transition revisions for user ${event.payload.userUuid}: ${userUuidOrError.getError()}`,
)
await this.domainEventPublisher.publish(
this.domainEventFactory.createTransitionStatusUpdatedEvent({
userUuid: event.payload.userUuid,
status: 'FAILED',
transitionType: 'revisions',
transitionTimestamp: event.payload.transitionTimestamp,
}),
)
return
}
if (await this.isAlreadyMigrated(userUuidOrError.getValue())) {
await this.domainEventPublisher.publish(
this.domainEventFactory.createTransitionStatusUpdatedEvent({
userUuid: event.payload.userUuid,
status: 'FINISHED',
transitionType: 'revisions',
transitionTimestamp: event.payload.transitionTimestamp,
}),
)
return
}
await this.domainEventPublisher.publish(
this.domainEventFactory.createTransitionStatusUpdatedEvent({
userUuid: event.payload.userUuid,
status: 'IN_PROGRESS',
transitionType: 'revisions',
transitionTimestamp: event.payload.transitionTimestamp,
}),
)
@@ -49,6 +84,7 @@ export class TransitionStatusUpdatedEventHandler implements DomainEventHandlerIn
userUuid: event.payload.userUuid,
status: 'FAILED',
transitionType: 'revisions',
transitionTimestamp: event.payload.transitionTimestamp,
}),
)
@@ -60,10 +96,23 @@ export class TransitionStatusUpdatedEventHandler implements DomainEventHandlerIn
userUuid: event.payload.userUuid,
status: 'FINISHED',
transitionType: 'revisions',
transitionTimestamp: event.payload.transitionTimestamp,
}),
)
return
}
}
private async isAlreadyMigrated(userUuid: Uuid): Promise<boolean> {
const totalRevisionsCountForUserInPrimary = await this.primaryRevisionsRepository.countByUserUuid(userUuid)
if (totalRevisionsCountForUserInPrimary > 0) {
this.logger.info(
`User ${userUuid.value} has ${totalRevisionsCountForUserInPrimary} revisions in primary database.`,
)
}
return totalRevisionsCountForUserInPrimary === 0
}
}

View File

@@ -1,455 +0,0 @@
import { Logger } from 'winston'
import { RevisionRepositoryInterface } from '../../../Revision/RevisionRepositoryInterface'
import { TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser } from './TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser'
import { Revision } from '../../../Revision/Revision'
import { ContentType, Dates, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
import { TimerInterface } from '@standardnotes/time'
describe('TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser', () => {
let primaryRevisionRepository: RevisionRepositoryInterface
let secondaryRevisionRepository: RevisionRepositoryInterface | null
let logger: Logger
let primaryRevision1: Revision
let primaryRevision2: Revision
let secondaryRevision1: Revision
let secondaryRevision2: Revision
let timer: TimerInterface
const createUseCase = () =>
new TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser(
primaryRevisionRepository,
secondaryRevisionRepository,
timer,
logger,
1,
)
beforeEach(() => {
primaryRevision1 = Revision.create(
{
itemUuid: Uuid.create('84c0f8e8-544a-4c7e-9adf-26209303bc1d').getValue(),
userUuid: Uuid.create('84c0f8e8-544a-4c7e-9adf-26209303bc1d').getValue(),
content: 'test',
contentType: ContentType.create('Note').getValue(),
itemsKeyId: 'test',
encItemKey: 'test',
authHash: 'test',
creationDate: new Date(1),
dates: Dates.create(new Date(1), new Date(2)).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
).getValue()
primaryRevision2 = Revision.create(
{
itemUuid: Uuid.create('84c0f8e8-544a-4c7e-9adf-26209303bc2d').getValue(),
userUuid: Uuid.create('84c0f8e8-544a-4c7e-9adf-26209303bc1d').getValue(),
content: 'test',
contentType: ContentType.create('Note').getValue(),
itemsKeyId: 'test',
encItemKey: 'test',
authHash: 'test',
creationDate: new Date(1),
dates: Dates.create(new Date(1), new Date(2)).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000001'),
).getValue()
secondaryRevision1 = Revision.create(
{
itemUuid: Uuid.create('84c0f8e8-544a-4c7e-9adf-26209303bc1d').getValue(),
userUuid: Uuid.create('84c0f8e8-544a-4c7e-9adf-26209303bc1d').getValue(),
content: 'test',
contentType: ContentType.create('Note').getValue(),
itemsKeyId: 'test',
encItemKey: 'test',
authHash: 'test',
creationDate: new Date(1),
dates: Dates.create(new Date(1), new Date(2)).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
).getValue()
secondaryRevision2 = Revision.create(
{
itemUuid: Uuid.create('84c0f8e8-544a-4c7e-9adf-26209303bc2d').getValue(),
userUuid: Uuid.create('84c0f8e8-544a-4c7e-9adf-26209303bc1d').getValue(),
content: 'test',
contentType: ContentType.create('Note').getValue(),
itemsKeyId: 'test',
encItemKey: 'test',
authHash: 'test',
creationDate: new Date(1),
dates: Dates.create(new Date(1), new Date(2)).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000001'),
).getValue()
primaryRevisionRepository = {} as jest.Mocked<RevisionRepositoryInterface>
primaryRevisionRepository.countByUserUuid = jest.fn().mockResolvedValue(2)
primaryRevisionRepository.findByUserUuid = jest
.fn()
.mockResolvedValueOnce([primaryRevision1])
.mockResolvedValueOnce([primaryRevision2])
.mockResolvedValueOnce([primaryRevision1])
.mockResolvedValueOnce([primaryRevision2])
primaryRevisionRepository.removeByUserUuid = jest.fn().mockResolvedValue(undefined)
secondaryRevisionRepository = {} as jest.Mocked<RevisionRepositoryInterface>
secondaryRevisionRepository.insert = jest.fn().mockResolvedValue(true)
secondaryRevisionRepository.removeByUserUuid = jest.fn().mockResolvedValue(undefined)
secondaryRevisionRepository.countByUserUuid = jest.fn().mockResolvedValueOnce(0).mockResolvedValueOnce(2)
secondaryRevisionRepository.findOneByUuid = jest
.fn()
.mockResolvedValueOnce(secondaryRevision1)
.mockResolvedValueOnce(secondaryRevision2)
logger = {} as jest.Mocked<Logger>
logger.error = jest.fn()
logger.info = jest.fn()
logger.debug = jest.fn()
timer = {} as jest.Mocked<TimerInterface>
timer.sleep = jest.fn()
timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123)
timer.convertMicrosecondsToTimeStructure = jest.fn().mockReturnValue({
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
milliseconds: 0,
})
})
describe('successfull transition', () => {
it('should transition Revisions from primary to secondary database', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeFalsy()
expect(primaryRevisionRepository.countByUserUuid).toHaveBeenCalledTimes(3)
expect(primaryRevisionRepository.countByUserUuid).toHaveBeenCalledWith(
Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
)
expect(primaryRevisionRepository.findByUserUuid).toHaveBeenCalledTimes(4)
expect(primaryRevisionRepository.findByUserUuid).toHaveBeenNthCalledWith(1, {
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
limit: 1,
offset: 0,
})
expect(primaryRevisionRepository.findByUserUuid).toHaveBeenNthCalledWith(2, {
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
limit: 1,
offset: 1,
})
expect(primaryRevisionRepository.findByUserUuid).toHaveBeenNthCalledWith(3, {
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
limit: 1,
offset: 0,
})
expect(primaryRevisionRepository.findByUserUuid).toHaveBeenNthCalledWith(4, {
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
limit: 1,
offset: 1,
})
expect((secondaryRevisionRepository as RevisionRepositoryInterface).insert).toHaveBeenCalledTimes(2)
expect((secondaryRevisionRepository as RevisionRepositoryInterface).insert).toHaveBeenCalledWith(primaryRevision1)
expect((secondaryRevisionRepository as RevisionRepositoryInterface).insert).toHaveBeenCalledWith(primaryRevision2)
expect((secondaryRevisionRepository as RevisionRepositoryInterface).removeByUserUuid).not.toHaveBeenCalled()
expect(primaryRevisionRepository.removeByUserUuid).toHaveBeenCalledTimes(1)
})
it('should log an error if deleting Revisions from primary database fails', async () => {
primaryRevisionRepository.removeByUserUuid = jest.fn().mockRejectedValue(new Error('error'))
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeFalsy()
expect(logger.error).toHaveBeenCalledTimes(1)
expect(logger.error).toHaveBeenCalledWith(
'Failed to clean up primary database revisions for user 00000000-0000-0000-0000-000000000000: Errored when deleting revisions for user 00000000-0000-0000-0000-000000000000: error',
)
})
})
describe('failed transition', () => {
it('should remove Revisions from secondary database if integrity check fails', async () => {
const secondaryRevision2WithDifferentContent = Revision.create({
...secondaryRevision2.props,
content: 'different-content',
}).getValue()
;(secondaryRevisionRepository as RevisionRepositoryInterface).findOneByUuid = jest
.fn()
.mockResolvedValueOnce(secondaryRevision1)
.mockResolvedValueOnce(secondaryRevision2WithDifferentContent)
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
expect((secondaryRevisionRepository as RevisionRepositoryInterface).removeByUserUuid).toHaveBeenCalledTimes(1)
expect(primaryRevisionRepository.removeByUserUuid).not.toHaveBeenCalled()
})
it('should remove Revisions from secondary database if migrating Revisions fails', async () => {
primaryRevisionRepository.findByUserUuid = jest
.fn()
.mockResolvedValueOnce([primaryRevision1])
.mockRejectedValueOnce(new Error('error'))
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual(
'Errored when migrating revisions for user 00000000-0000-0000-0000-000000000000: error',
)
expect((secondaryRevisionRepository as RevisionRepositoryInterface).removeByUserUuid).toHaveBeenCalledTimes(1)
expect(primaryRevisionRepository.removeByUserUuid).not.toHaveBeenCalled()
})
it('should return an error for a specific revision if it errors when saving to secondary database', async () => {
;(secondaryRevisionRepository as RevisionRepositoryInterface).insert = jest
.fn()
.mockResolvedValueOnce(true)
.mockRejectedValueOnce(new Error('error'))
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '84c0f8e8-544a-4c7e-9adf-26209303bc1d',
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual(
'Errored when saving revision 00000000-0000-0000-0000-000000000001 to secondary database: error',
)
})
it('should log an error if deleting Revisions from secondary database fails upon migration failure', async () => {
primaryRevisionRepository.findByUserUuid = jest
.fn()
.mockResolvedValueOnce([primaryRevision1])
.mockRejectedValueOnce(new Error('error'))
;(secondaryRevisionRepository as RevisionRepositoryInterface).removeByUserUuid = jest
.fn()
.mockRejectedValue(new Error('error'))
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
expect(logger.error).toHaveBeenCalledTimes(1)
expect(logger.error).toHaveBeenCalledWith(
'Failed to clean up secondary database revisions for user 00000000-0000-0000-0000-000000000000: Errored when deleting revisions for user 00000000-0000-0000-0000-000000000000: error',
)
})
it('should log an error if deleting Revisions from secondary database fails upon integrity check failure', async () => {
const secondaryRevision2WithDifferentContent = Revision.create({
...secondaryRevision2.props,
content: 'different-content',
}).getValue()
;(secondaryRevisionRepository as RevisionRepositoryInterface).findOneByUuid = jest
.fn()
.mockResolvedValueOnce(secondaryRevision1)
.mockResolvedValueOnce(secondaryRevision2WithDifferentContent)
;(secondaryRevisionRepository as RevisionRepositoryInterface).removeByUserUuid = jest
.fn()
.mockRejectedValue(new Error('error'))
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
expect(logger.error).toHaveBeenCalledTimes(1)
expect(logger.error).toHaveBeenCalledWith(
'Failed to clean up secondary database revisions for user 00000000-0000-0000-0000-000000000000: Errored when deleting revisions for user 00000000-0000-0000-0000-000000000000: error',
)
})
it('should not perform the transition if secondary Revision repository is not set', async () => {
secondaryRevisionRepository = null
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual('Secondary revision repository is not set')
expect(primaryRevisionRepository.countByUserUuid).not.toHaveBeenCalled()
expect(primaryRevisionRepository.findByUserUuid).not.toHaveBeenCalled()
expect(primaryRevisionRepository.removeByUserUuid).not.toHaveBeenCalled()
})
it('should not perform the transition if the user uuid is invalid', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: 'invalid-uuid',
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual('Given value is not a valid uuid: invalid-uuid')
expect(primaryRevisionRepository.countByUserUuid).not.toHaveBeenCalled()
expect(primaryRevisionRepository.findByUserUuid).not.toHaveBeenCalled()
expect(primaryRevisionRepository.removeByUserUuid).not.toHaveBeenCalled()
})
it('should fail integrity check if the Revision count is not the same in both databases', async () => {
;(secondaryRevisionRepository as RevisionRepositoryInterface).countByUserUuid = jest
.fn()
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(1)
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual(
'Total revisions count for user 00000000-0000-0000-0000-000000000000 in primary database (2) does not match total revisions count in secondary database (1)',
)
expect(primaryRevisionRepository.countByUserUuid).toHaveBeenCalledTimes(3)
expect(primaryRevisionRepository.countByUserUuid).toHaveBeenCalledWith(
Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
)
expect((secondaryRevisionRepository as RevisionRepositoryInterface).countByUserUuid).toHaveBeenCalledTimes(2)
expect(primaryRevisionRepository.removeByUserUuid).not.toHaveBeenCalled()
expect((secondaryRevisionRepository as RevisionRepositoryInterface).removeByUserUuid).toHaveBeenCalledTimes(1)
})
it('should fail if one Revision is not found in the secondary database', async () => {
;(secondaryRevisionRepository as RevisionRepositoryInterface).findOneByUuid = jest
.fn()
.mockResolvedValueOnce(secondaryRevision1)
.mockResolvedValueOnce(null)
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual('Revision 00000000-0000-0000-0000-000000000001 not found in secondary database')
expect(primaryRevisionRepository.countByUserUuid).toHaveBeenCalledTimes(3)
expect(primaryRevisionRepository.countByUserUuid).toHaveBeenCalledWith(
Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
)
expect(primaryRevisionRepository.removeByUserUuid).not.toHaveBeenCalled()
expect((secondaryRevisionRepository as RevisionRepositoryInterface).removeByUserUuid).toHaveBeenCalledTimes(1)
})
it('should fail if an error is thrown during integrity check between primary and secondary database', async () => {
;(secondaryRevisionRepository as RevisionRepositoryInterface).countByUserUuid = jest
.fn()
.mockReturnValueOnce(0)
.mockRejectedValue(new Error('error'))
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual('Errored when checking integrity between primary and secondary database: error')
expect(primaryRevisionRepository.removeByUserUuid).not.toHaveBeenCalled()
expect((secondaryRevisionRepository as RevisionRepositoryInterface).removeByUserUuid).toHaveBeenCalledTimes(1)
})
it('should fail if a revisions did not save in the secondary database', async () => {
;(secondaryRevisionRepository as RevisionRepositoryInterface).insert = jest.fn().mockResolvedValue(false)
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual(
'Failed to save revision 00000000-0000-0000-0000-000000000000 to secondary database',
)
expect(primaryRevisionRepository.removeByUserUuid).not.toHaveBeenCalled()
expect((secondaryRevisionRepository as RevisionRepositoryInterface).removeByUserUuid).toHaveBeenCalledTimes(1)
})
})
it('should not migrate revisions if there are no revisions in the primary database', async () => {
primaryRevisionRepository.countByUserUuid = jest.fn().mockResolvedValue(0)
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeFalsy()
expect(primaryRevisionRepository.countByUserUuid).toHaveBeenCalledTimes(1)
expect(primaryRevisionRepository.countByUserUuid).toHaveBeenCalledWith(
Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
)
expect(primaryRevisionRepository.findByUserUuid).not.toHaveBeenCalled()
expect((secondaryRevisionRepository as RevisionRepositoryInterface).insert).not.toHaveBeenCalled()
expect(primaryRevisionRepository.removeByUserUuid).not.toHaveBeenCalled()
expect((secondaryRevisionRepository as RevisionRepositoryInterface).removeByUserUuid).not.toHaveBeenCalled()
})
it('should not migrate revisions if there are already revisions in the secondary database', async () => {
;(secondaryRevisionRepository as RevisionRepositoryInterface).countByUserUuid = jest.fn().mockResolvedValueOnce(1)
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
expect(primaryRevisionRepository.findByUserUuid).not.toHaveBeenCalled()
expect((secondaryRevisionRepository as RevisionRepositoryInterface).insert).not.toHaveBeenCalled()
expect(primaryRevisionRepository.removeByUserUuid).not.toHaveBeenCalled()
expect((secondaryRevisionRepository as RevisionRepositoryInterface).removeByUserUuid).not.toHaveBeenCalled()
})
})

View File

@@ -1,9 +1,11 @@
/* istanbul ignore file */
import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
import { TimerInterface } from '@standardnotes/time'
import { Logger } from 'winston'
import { TransitionRevisionsFromPrimaryToSecondaryDatabaseForUserDTO } from './TransitionRevisionsFromPrimaryToSecondaryDatabaseForUserDTO'
import { RevisionRepositoryInterface } from '../../../Revision/RevisionRepositoryInterface'
import { Revision } from '../../../Revision/Revision'
export class TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser implements UseCaseInterface<void> {
constructor(
@@ -27,27 +29,54 @@ export class TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser implements
}
const userUuid = userUuidOrError.getValue()
if (await this.isAlreadyMigrated(userUuid)) {
this.logger.info(`Revisions for user ${userUuid.value} are already migrated`)
return Result.ok()
}
let newRevisionsInSecondaryCount = 0
let updatedRevisionsInSecondary: Revision[] = []
if (await this.hasAlreadyDataInSecondaryDatabase(userUuid)) {
return Result.fail(`Revisions for user ${userUuid.value} already exist in secondary database`)
const { alreadyExistingInPrimary, newRevisionsInSecondary, updatedInSecondary } =
await this.getNewRevisionsCreatedInSecondaryDatabase(userUuid)
for (const existingRevision of alreadyExistingInPrimary) {
this.logger.info(`Removing revision ${existingRevision.id.toString()} from secondary database`)
await (this.secondRevisionsRepository as RevisionRepositoryInterface).removeOneByUuid(
Uuid.create(existingRevision.id.toString()).getValue(),
userUuid,
)
}
if (newRevisionsInSecondary.length > 0) {
this.logger.info(
`Found ${newRevisionsInSecondary.length} new revisions in secondary database for user ${userUuid.value}`,
)
}
newRevisionsInSecondaryCount = newRevisionsInSecondary.length
if (updatedInSecondary.length > 0) {
this.logger.info(
`Found ${updatedInSecondary.length} updated revisions in secondary database for user ${userUuid.value}`,
)
}
updatedRevisionsInSecondary = updatedInSecondary
}
const updatedRevisionsInSecondaryCount = updatedRevisionsInSecondary.length
await this.allowForSecondaryDatabaseToCatchUp()
const migrationTimeStart = this.timer.getTimestampInMicroseconds()
this.logger.debug(`Transitioning revisions for user ${userUuid.value}`)
const migrationResult = await this.migrateRevisionsForUser(userUuid)
const migrationResult = await this.migrateRevisionsForUser(userUuid, updatedRevisionsInSecondary)
if (migrationResult.isFailed()) {
const cleanupResult = await this.deleteRevisionsForUser(userUuid, this.secondRevisionsRepository)
if (cleanupResult.isFailed()) {
this.logger.error(
`Failed to clean up secondary database revisions for user ${userUuid.value}: ${cleanupResult.getError()}`,
)
if (newRevisionsInSecondaryCount === 0 && updatedRevisionsInSecondaryCount === 0) {
const cleanupResult = await this.deleteRevisionsForUser(userUuid, this.secondRevisionsRepository)
if (cleanupResult.isFailed()) {
this.logger.error(
`Failed to clean up secondary database revisions for user ${userUuid.value}: ${cleanupResult.getError()}`,
)
}
}
return Result.fail(migrationResult.getError())
@@ -55,13 +84,18 @@ export class TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser implements
await this.allowForSecondaryDatabaseToCatchUp()
const integrityCheckResult = await this.checkIntegrityBetweenPrimaryAndSecondaryDatabase(userUuid)
const integrityCheckResult = await this.checkIntegrityBetweenPrimaryAndSecondaryDatabase(
userUuid,
newRevisionsInSecondaryCount,
)
if (integrityCheckResult.isFailed()) {
const cleanupResult = await this.deleteRevisionsForUser(userUuid, this.secondRevisionsRepository)
if (cleanupResult.isFailed()) {
this.logger.error(
`Failed to clean up secondary database revisions for user ${userUuid.value}: ${cleanupResult.getError()}`,
)
if (newRevisionsInSecondaryCount === 0 && updatedRevisionsInSecondaryCount === 0) {
const cleanupResult = await this.deleteRevisionsForUser(userUuid, this.secondRevisionsRepository)
if (cleanupResult.isFailed()) {
this.logger.error(
`Failed to clean up secondary database revisions for user ${userUuid.value}: ${cleanupResult.getError()}`,
)
}
}
return Result.fail(integrityCheckResult.getError())
@@ -86,7 +120,10 @@ export class TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser implements
return Result.ok()
}
private async migrateRevisionsForUser(userUuid: Uuid): Promise<Result<void>> {
private async migrateRevisionsForUser(
userUuid: Uuid,
updatedRevisionsInSecondary: Revision[],
): Promise<Result<void>> {
try {
const totalRevisionsCountForUser = await this.primaryRevisionsRepository.countByUserUuid(userUuid)
let totalRevisionsCountTransitionedToSecondary = 0
@@ -102,6 +139,14 @@ export class TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser implements
for (const revision of revisions) {
try {
if (
updatedRevisionsInSecondary.find(
(updatedRevision) => updatedRevision.id.toString() === revision.id.toString(),
)
) {
continue
}
this.logger.debug(
`Transitioning revision #${
totalRevisionsCountTransitionedToSecondary + 1
@@ -154,16 +199,93 @@ export class TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser implements
this.secondRevisionsRepository as RevisionRepositoryInterface
).countByUserUuid(userUuid)
return totalRevisionsCountForUserInSecondary > 0
const hasAlreadyDataInSecondaryDatabase = totalRevisionsCountForUserInSecondary > 0
if (hasAlreadyDataInSecondaryDatabase) {
this.logger.info(
`User ${userUuid.value} has already ${totalRevisionsCountForUserInSecondary} revisions in secondary database`,
)
}
return hasAlreadyDataInSecondaryDatabase
}
private async isAlreadyMigrated(userUuid: Uuid): Promise<boolean> {
const totalRevisionsCountForUserInPrimary = await this.primaryRevisionsRepository.countByUserUuid(userUuid)
private async getNewRevisionsCreatedInSecondaryDatabase(userUuid: Uuid): Promise<{
alreadyExistingInPrimary: Revision[]
newRevisionsInSecondary: Revision[]
updatedInSecondary: Revision[]
}> {
const revisions = await (this.secondRevisionsRepository as RevisionRepositoryInterface).findByUserUuid({
userUuid: userUuid,
})
return totalRevisionsCountForUserInPrimary === 0
const alreadyExistingInPrimary: Revision[] = []
const newRevisionsInSecondary: Revision[] = []
const updatedInSecondary: Revision[] = []
for (const revision of revisions) {
const { revisionInPrimary, newerRevisionInSecondary } =
await this.checkIfRevisionExistsInPrimaryDatabase(revision)
if (revisionInPrimary !== null) {
alreadyExistingInPrimary.push(revision)
continue
}
if (newerRevisionInSecondary !== null) {
updatedInSecondary.push(newerRevisionInSecondary)
continue
}
if (revisionInPrimary === null && newerRevisionInSecondary === null) {
newRevisionsInSecondary.push(revision)
continue
}
}
return {
alreadyExistingInPrimary: alreadyExistingInPrimary,
newRevisionsInSecondary: newRevisionsInSecondary,
updatedInSecondary: updatedInSecondary,
}
}
private async checkIntegrityBetweenPrimaryAndSecondaryDatabase(userUuid: Uuid): Promise<Result<boolean>> {
private async checkIfRevisionExistsInPrimaryDatabase(
revision: Revision,
): Promise<{ revisionInPrimary: Revision | null; newerRevisionInSecondary: Revision | null }> {
const revisionInPrimary = await this.primaryRevisionsRepository.findOneByUuid(
Uuid.create(revision.id.toString()).getValue(),
revision.props.userUuid as Uuid,
[],
)
if (revisionInPrimary === null) {
return {
revisionInPrimary: null,
newerRevisionInSecondary: null,
}
}
if (!revision.isIdenticalTo(revisionInPrimary)) {
this.logger.error(
`Revision ${revision.id.toString()} is not identical in primary and secondary database. Revision in secondary database: ${JSON.stringify(
revision,
)}, revision in primary database: ${JSON.stringify(revisionInPrimary)}`,
)
return {
revisionInPrimary: null,
newerRevisionInSecondary:
revision.props.dates.updatedAt > revisionInPrimary.props.dates.updatedAt ? revision : null,
}
}
return {
revisionInPrimary: revisionInPrimary,
newerRevisionInSecondary: null,
}
}
private async checkIntegrityBetweenPrimaryAndSecondaryDatabase(
userUuid: Uuid,
newRevisionsInSecondaryCount: number,
): Promise<Result<boolean>> {
try {
const totalRevisionsCountForUserInPrimary = await this.primaryRevisionsRepository.countByUserUuid(userUuid)
@@ -206,9 +328,12 @@ export class TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser implements
this.secondRevisionsRepository as RevisionRepositoryInterface
).countByUserUuid(userUuid)
if (totalRevisionsCountForUserInPrimary !== totalRevisionsCountForUserInSecondary) {
if (
totalRevisionsCountForUserInPrimary + newRevisionsInSecondaryCount !==
totalRevisionsCountForUserInSecondary
) {
return Result.fail(
`Total revisions count for user ${userUuid.value} in primary database (${totalRevisionsCountForUserInPrimary}) does not match total revisions count in secondary database (${totalRevisionsCountForUserInSecondary})`,
`Total revisions count for user ${userUuid.value} in primary database (${totalRevisionsCountForUserInPrimary} + ${newRevisionsInSecondaryCount}) does not match total revisions count in secondary database (${totalRevisionsCountForUserInSecondary})`,
)
}

View File

@@ -23,6 +23,7 @@ describe('TriggerTransitionFromPrimaryToSecondaryDatabaseForUser', () => {
await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
transitionTimestamp: 123,
})
expect(domainEventPubliser.publish).toHaveBeenCalled()

View File

@@ -15,6 +15,7 @@ export class TriggerTransitionFromPrimaryToSecondaryDatabaseForUser implements U
userUuid: dto.userUuid,
status: 'STARTED',
transitionType: 'revisions',
transitionTimestamp: dto.transitionTimestamp,
})
await this.domainEventPubliser.publish(event)

View File

@@ -1,3 +1,4 @@
export interface TriggerTransitionFromPrimaryToSecondaryDatabaseForUserDTO {
userUuid: string
transitionTimestamp: number
}

View File

@@ -1,5 +1,5 @@
import { Request, Response } from 'express'
import { controller, httpDelete, httpGet, httpPost, results } from 'inversify-express-utils'
import { controller, httpDelete, httpGet, results } from 'inversify-express-utils'
import { inject } from 'inversify'
import TYPES from '../../Bootstrap/Types'
@@ -12,7 +12,6 @@ import { Revision } from '../../Domain/Revision/Revision'
import { RevisionMetadata } from '../../Domain/Revision/RevisionMetadata'
import { RevisionHttpRepresentation } from '../../Mapping/Http/RevisionHttpRepresentation'
import { RevisionMetadataHttpRepresentation } from '../../Mapping/Http/RevisionMetadataHttpRepresentation'
import { TriggerTransitionFromPrimaryToSecondaryDatabaseForUser } from '../../Domain/UseCase/Transition/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser'
@controller('', TYPES.Revisions_ApiGatewayAuthMiddleware)
export class AnnotatedRevisionsController extends BaseRevisionsController {
@@ -24,17 +23,8 @@ export class AnnotatedRevisionsController extends BaseRevisionsController {
override revisionHttpMapper: MapperInterface<Revision, RevisionHttpRepresentation>,
@inject(TYPES.Revisions_RevisionMetadataHttpMapper)
override revisionMetadataHttpMapper: MapperInterface<RevisionMetadata, RevisionMetadataHttpRepresentation>,
@inject(TYPES.Revisions_TriggerTransitionFromPrimaryToSecondaryDatabaseForUser)
override triggerTransitionFromPrimaryToSecondaryDatabaseForUser: TriggerTransitionFromPrimaryToSecondaryDatabaseForUser,
) {
super(
getRevisionsMetadata,
doGetRevision,
doDeleteRevision,
revisionHttpMapper,
revisionMetadataHttpMapper,
triggerTransitionFromPrimaryToSecondaryDatabaseForUser,
)
super(getRevisionsMetadata, doGetRevision, doDeleteRevision, revisionHttpMapper, revisionMetadataHttpMapper)
}
@httpGet('/items/:itemUuid/revisions')
@@ -51,9 +41,4 @@ export class AnnotatedRevisionsController extends BaseRevisionsController {
override async deleteRevision(request: Request, response: Response): Promise<results.JsonResult> {
return super.deleteRevision(request, response)
}
@httpPost('/revisions/transition')
override async transition(request: Request, response: Response): Promise<results.JsonResult> {
return super.transition(request, response)
}
}

View File

@@ -11,7 +11,6 @@ import { GetRevision } from '../../../Domain/UseCase/GetRevision/GetRevision'
import { GetRevisionsMetada } from '../../../Domain/UseCase/GetRevisionsMetada/GetRevisionsMetada'
import { RevisionHttpRepresentation } from '../../../Mapping/Http/RevisionHttpRepresentation'
import { RevisionMetadataHttpRepresentation } from '../../../Mapping/Http/RevisionMetadataHttpRepresentation'
import { TriggerTransitionFromPrimaryToSecondaryDatabaseForUser } from '../../../Domain/UseCase/Transition/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser'
export class BaseRevisionsController extends BaseHttpController {
constructor(
@@ -20,7 +19,6 @@ export class BaseRevisionsController extends BaseHttpController {
protected doDeleteRevision: DeleteRevision,
protected revisionHttpMapper: MapperInterface<Revision, RevisionHttpRepresentation>,
protected revisionMetadataHttpMapper: MapperInterface<RevisionMetadata, RevisionMetadataHttpRepresentation>,
protected triggerTransitionFromPrimaryToSecondaryDatabaseForUser: TriggerTransitionFromPrimaryToSecondaryDatabaseForUser,
private controllerContainer?: ControllerContainerInterface,
) {
super()
@@ -29,7 +27,6 @@ export class BaseRevisionsController extends BaseHttpController {
this.controllerContainer.register('revisions.revisions.getRevisions', this.getRevisions.bind(this))
this.controllerContainer.register('revisions.revisions.getRevision', this.getRevision.bind(this))
this.controllerContainer.register('revisions.revisions.deleteRevision', this.deleteRevision.bind(this))
this.controllerContainer.register('revisions.revisions.transition', this.transition.bind(this))
}
}
@@ -108,23 +105,4 @@ export class BaseRevisionsController extends BaseHttpController {
message: revisionOrError.getValue(),
})
}
async transition(_request: Request, response: Response): Promise<results.JsonResult> {
const result = await this.triggerTransitionFromPrimaryToSecondaryDatabaseForUser.execute({
userUuid: response.locals.user.uuid,
})
if (result.isFailed()) {
return this.json(
{
error: { message: result.getError() },
},
400,
)
}
response.setHeader('x-invalidate-cache', response.locals.user.uuid)
return this.json({ success: true })
}
}

View File

@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.20.44](https://github.com/standardnotes/server/compare/@standardnotes/scheduler-server@1.20.43...@standardnotes/scheduler-server@1.20.44) (2023-09-13)
**Note:** Version bump only for package @standardnotes/scheduler-server
## [1.20.43](https://github.com/standardnotes/server/compare/@standardnotes/scheduler-server@1.20.42...@standardnotes/scheduler-server@1.20.43) (2023-09-12)
**Note:** Version bump only for package @standardnotes/scheduler-server

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/scheduler-server",
"version": "1.20.43",
"version": "1.20.44",
"engines": {
"node": ">=18.0.0 <21.0.0"
},

View File

@@ -3,6 +3,50 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.95.17](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.95.16...@standardnotes/syncing-server@1.95.17) (2023-09-13)
### Bug Fixes
* adjust transition timestamps to be universal ([c7807d0](https://github.com/standardnotes/syncing-server-js/commit/c7807d0f9e69ce572c4c03ff606375d706f24d9f))
## [1.95.16](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.95.15...@standardnotes/syncing-server@1.95.16) (2023-09-13)
### Bug Fixes
* include handling updated items in revisions in secondary ([fbcb45c](https://github.com/standardnotes/syncing-server-js/commit/fbcb45c3a23fde09702fae7bfcb409bdbb610191))
## [1.95.15](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.95.14...@standardnotes/syncing-server@1.95.15) (2023-09-13)
### Bug Fixes
* display transition progress in logs ([38685c1](https://github.com/standardnotes/syncing-server-js/commit/38685c1861b13e398dd96aa39f2cf1aece2090fb))
## [1.95.14](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.95.13...@standardnotes/syncing-server@1.95.14) (2023-09-13)
### Bug Fixes
* setting status for already migrated users ([9be4c00](https://github.com/standardnotes/syncing-server-js/commit/9be4c002b755fea057489b6077b297162223aefe))
## [1.95.13](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.95.12...@standardnotes/syncing-server@1.95.13) (2023-09-13)
### Bug Fixes
* cleanup only for 0 new items ([b1d88b1](https://github.com/standardnotes/syncing-server-js/commit/b1d88b15be78a48224963e337a222fb675ed2692))
* **syncing-server:** add catch up timeout for secondary db ([ff78285](https://github.com/standardnotes/syncing-server-js/commit/ff78285e43db849bdc44caa36f602150562b4d81))
* **syncing-server:** case insensitive integrity check ([d5536f5](https://github.com/standardnotes/syncing-server-js/commit/d5536f54304e2aecd59dbece7650254f7c2101bb))
## [1.95.12](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.95.11...@standardnotes/syncing-server@1.95.12) (2023-09-12)
### Bug Fixes
* sync between primary and secondary database with diff ([fab5d18](https://github.com/standardnotes/syncing-server-js/commit/fab5d180645e0a6fa0c9c67205d44f27c8a65c8b))
## [1.95.11](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.95.10...@standardnotes/syncing-server@1.95.11) (2023-09-12)
### Bug Fixes
* **syncing-server:** binding ([e91a832](https://github.com/standardnotes/syncing-server-js/commit/e91a8321527ac269ba9822ce270184db5bc57099))
## [1.95.10](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.95.9...@standardnotes/syncing-server@1.95.10) (2023-09-12)
### Bug Fixes

View File

@@ -6,16 +6,19 @@ import { ContainerConfigLoader } from '../src/Bootstrap/Container'
import TYPES from '../src/Bootstrap/Types'
import { Env } from '../src/Bootstrap/Env'
import { TriggerTransitionFromPrimaryToSecondaryDatabaseForUser } from '../src/Domain/UseCase/Transition/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser'
import { TimerInterface } from '@standardnotes/time'
const inputArgs = process.argv.slice(2)
const userUuid = inputArgs[0]
const requestTransition = async (
triggerTransitionFromPrimaryToSecondaryDatabaseForUser: TriggerTransitionFromPrimaryToSecondaryDatabaseForUser,
timer: TimerInterface,
logger: Logger,
): Promise<void> => {
const result = await triggerTransitionFromPrimaryToSecondaryDatabaseForUser.execute({
userUuid,
transitionTimestamp: timer.getTimestampInMicroseconds(),
})
if (result.isFailed()) {
logger.error(`Could not trigger transition for user ${userUuid}: ${result.getError()}`)
@@ -35,8 +38,9 @@ void container.load().then((container) => {
const triggerTransitionFromPrimaryToSecondaryDatabaseForUser: TriggerTransitionFromPrimaryToSecondaryDatabaseForUser =
container.get(TYPES.Sync_TriggerTransitionFromPrimaryToSecondaryDatabaseForUser)
const timer = container.get<TimerInterface>(TYPES.Sync_Timer)
Promise.resolve(requestTransition(triggerTransitionFromPrimaryToSecondaryDatabaseForUser, logger))
Promise.resolve(requestTransition(triggerTransitionFromPrimaryToSecondaryDatabaseForUser, timer, logger))
.then(() => {
logger.info(`Transition triggered for user ${userUuid}`)

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/syncing-server",
"version": "1.95.10",
"version": "1.95.17",
"engines": {
"node": ">=18.0.0 <21.0.0"
},

View File

@@ -666,14 +666,14 @@ export class ContainerConfigLoader {
.bind<SyncItems>(TYPES.Sync_SyncItems)
.toConstantValue(
new SyncItems(
container.get(TYPES.Sync_ItemRepositoryResolver),
container.get(TYPES.Sync_GetItems),
container.get(TYPES.Sync_SaveItems),
container.get(TYPES.Sync_GetSharedVaults),
container.get(TYPES.Sync_GetSharedVaultInvitesSentToUser),
container.get(TYPES.Sync_GetMessagesSentToUser),
container.get(TYPES.Sync_GetUserNotifications),
container.get(TYPES.Sync_Timer),
container.get<ItemRepositoryResolverInterface>(TYPES.Sync_ItemRepositoryResolver),
container.get<GetItems>(TYPES.Sync_GetItems),
container.get<SaveItems>(TYPES.Sync_SaveItems),
container.get<GetSharedVaults>(TYPES.Sync_GetSharedVaults),
container.get<GetSharedVaultInvitesSentToUser>(TYPES.Sync_GetSharedVaultInvitesSentToUser),
container.get<GetMessagesSentToUser>(TYPES.Sync_GetMessagesSentToUser),
container.get<GetUserNotifications>(TYPES.Sync_GetUserNotifications),
container.get<Logger>(TYPES.Sync_Logger),
),
)
container.bind<CheckIntegrity>(TYPES.Sync_CheckIntegrity).toDynamicValue((context: interfaces.Context) => {
@@ -952,6 +952,7 @@ export class ContainerConfigLoader {
.bind<TransitionStatusUpdatedEventHandler>(TYPES.Sync_TransitionStatusUpdatedEventHandler)
.toConstantValue(
new TransitionStatusUpdatedEventHandler(
container.get<ItemRepositoryInterface>(TYPES.Sync_SQLItemRepository),
container.get<TransitionItemsFromPrimaryToSecondaryDatabaseForUser>(
TYPES.Sync_TransitionItemsFromPrimaryToSecondaryDatabaseForUser,
),
@@ -1089,9 +1090,6 @@ export class ContainerConfigLoader {
container.get<SyncItems>(TYPES.Sync_SyncItems),
container.get<CheckIntegrity>(TYPES.Sync_CheckIntegrity),
container.get<GetItem>(TYPES.Sync_GetItem),
container.get<TriggerTransitionFromPrimaryToSecondaryDatabaseForUser>(
TYPES.Sync_TriggerTransitionFromPrimaryToSecondaryDatabaseForUser,
),
container.get<MapperInterface<Item, ItemHttpRepresentation>>(TYPES.Sync_ItemHttpMapper),
container.get<SyncResponseFactoryResolverInterface>(TYPES.Sync_SyncResponseFactoryResolver),
container.get<ControllerContainerInterface>(TYPES.Sync_ControllerContainer),

View File

@@ -172,6 +172,7 @@ export class DomainEventFactory implements DomainEventFactoryInterface {
createTransitionStatusUpdatedEvent(dto: {
userUuid: string
transitionType: 'items' | 'revisions'
transitionTimestamp: number
status: 'STARTED' | 'FAILED' | 'FINISHED'
}): TransitionStatusUpdatedEvent {
return {

View File

@@ -52,6 +52,7 @@ export interface DomainEventFactoryInterface {
createTransitionStatusUpdatedEvent(dto: {
userUuid: string
transitionType: 'items' | 'revisions'
transitionTimestamp: number
status: 'STARTED' | 'IN_PROGRESS' | 'FAILED' | 'FINISHED'
}): TransitionStatusUpdatedEvent
createEmailRequestedEvent(dto: {

View File

@@ -18,6 +18,7 @@ export class TransitionRequestedEventHandler implements DomainEventHandlerInterf
const result = await this.triggerTransitionFromPrimaryToSecondaryDatabaseForUser.execute({
userUuid: event.payload.userUuid,
transitionTimestamp: event.payload.timestamp,
})
if (result.isFailed()) {

View File

@@ -6,9 +6,11 @@ import {
import { Logger } from 'winston'
import { TransitionItemsFromPrimaryToSecondaryDatabaseForUser } from '../UseCase/Transition/TransitionItemsFromPrimaryToSecondaryDatabaseForUser/TransitionItemsFromPrimaryToSecondaryDatabaseForUser'
import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface'
import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface'
export class TransitionStatusUpdatedEventHandler implements DomainEventHandlerInterface {
constructor(
private primaryItemRepository: ItemRepositoryInterface,
private transitionItemsFromPrimaryToSecondaryDatabaseForUser: TransitionItemsFromPrimaryToSecondaryDatabaseForUser,
private domainEventPublisher: DomainEventPublisherInterface,
private domainEventFactory: DomainEventFactoryInterface,
@@ -17,11 +19,25 @@ export class TransitionStatusUpdatedEventHandler implements DomainEventHandlerIn
async handle(event: TransitionStatusUpdatedEvent): Promise<void> {
if (event.payload.status === 'STARTED' && event.payload.transitionType === 'items') {
if (await this.isAlreadyMigrated(event.payload.userUuid)) {
await this.domainEventPublisher.publish(
this.domainEventFactory.createTransitionStatusUpdatedEvent({
userUuid: event.payload.userUuid,
status: 'FINISHED',
transitionType: 'items',
transitionTimestamp: event.payload.transitionTimestamp,
}),
)
return
}
await this.domainEventPublisher.publish(
this.domainEventFactory.createTransitionStatusUpdatedEvent({
userUuid: event.payload.userUuid,
status: 'IN_PROGRESS',
transitionType: 'items',
transitionTimestamp: event.payload.transitionTimestamp,
}),
)
@@ -37,6 +53,7 @@ export class TransitionStatusUpdatedEventHandler implements DomainEventHandlerIn
userUuid: event.payload.userUuid,
status: 'FAILED',
transitionType: 'items',
transitionTimestamp: event.payload.transitionTimestamp,
}),
)
@@ -48,8 +65,19 @@ export class TransitionStatusUpdatedEventHandler implements DomainEventHandlerIn
userUuid: event.payload.userUuid,
status: 'FINISHED',
transitionType: 'items',
transitionTimestamp: event.payload.transitionTimestamp,
}),
)
}
}
private async isAlreadyMigrated(userUuid: string): Promise<boolean> {
const totalItemsCountForUser = await this.primaryItemRepository.countAll({ userUuid })
if (totalItemsCountForUser > 0) {
this.logger.info(`User ${userUuid} has ${totalItemsCountForUser} items in primary database.`)
}
return totalItemsCountForUser === 0
}
}

View File

@@ -22,7 +22,7 @@ describe('CheckIntegrity', () => {
content_type: ContentType.TYPES.Note,
},
{
uuid: '2-3-4',
uuid: '2-3-4-a',
updated_at_timestamp: 2,
content_type: ContentType.TYPES.Note,
},
@@ -56,7 +56,7 @@ describe('CheckIntegrity', () => {
updated_at_timestamp: 1,
},
{
uuid: '2-3-4',
uuid: '2-3-4-A',
updated_at_timestamp: 2,
},
{
@@ -82,7 +82,7 @@ describe('CheckIntegrity', () => {
updated_at_timestamp: 1,
},
{
uuid: '2-3-4',
uuid: '2-3-4-A',
updated_at_timestamp: 1,
},
{
@@ -98,7 +98,7 @@ describe('CheckIntegrity', () => {
})
expect(result.getValue()).toEqual([
{
uuid: '2-3-4',
uuid: '2-3-4-A',
updated_at_timestamp: 2,
},
])
@@ -113,7 +113,7 @@ describe('CheckIntegrity', () => {
updated_at_timestamp: 1,
},
{
uuid: '2-3-4',
uuid: '2-3-4-A',
updated_at_timestamp: 2,
},
{
@@ -140,7 +140,7 @@ describe('CheckIntegrity', () => {
updated_at_timestamp: 1,
},
{
uuid: '2-3-4',
uuid: '2-3-4-A',
updated_at_timestamp: 2,
},
{

View File

@@ -20,15 +20,17 @@ export class CheckIntegrity implements UseCaseInterface<IntegrityPayload[]> {
const serverItemIntegrityPayloadsMap = new Map<string, ExtendedIntegrityPayload>()
for (const serverItemIntegrityPayload of serverItemIntegrityPayloads) {
serverItemIntegrityPayloadsMap.set(serverItemIntegrityPayload.uuid, serverItemIntegrityPayload)
serverItemIntegrityPayloadsMap.set(serverItemIntegrityPayload.uuid.toLowerCase(), serverItemIntegrityPayload)
}
const clientItemIntegrityPayloadsMap = new Map<string, number>()
const caseInsensitiveUuidsMap = new Map<string, string>()
for (const clientItemIntegrityPayload of dto.integrityPayloads) {
clientItemIntegrityPayloadsMap.set(
clientItemIntegrityPayload.uuid,
clientItemIntegrityPayload.uuid.toLowerCase(),
clientItemIntegrityPayload.updated_at_timestamp,
)
caseInsensitiveUuidsMap.set(clientItemIntegrityPayload.uuid.toLowerCase(), clientItemIntegrityPayload.uuid)
}
const mismatches: IntegrityPayload[] = []
@@ -58,7 +60,7 @@ export class CheckIntegrity implements UseCaseInterface<IntegrityPayload[]> {
serverItemIntegrityPayload.content_type !== ContentType.TYPES.ItemsKey
) {
mismatches.unshift({
uuid: serverItemIntegrityPayloadUuid,
uuid: caseInsensitiveUuidsMap.get(serverItemIntegrityPayloadUuid) as string,
updated_at_timestamp: serverItemIntegrityPayloadUpdatedAtTimestamp,
})
}

View File

@@ -1,417 +0,0 @@
import { Logger } from 'winston'
import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
import { TransitionItemsFromPrimaryToSecondaryDatabaseForUser } from './TransitionItemsFromPrimaryToSecondaryDatabaseForUser'
import { Item } from '../../../Item/Item'
import { ContentType, Dates, Timestamps, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
import { TimerInterface } from '@standardnotes/time'
describe('TransitionItemsFromPrimaryToSecondaryDatabaseForUser', () => {
let primaryItemRepository: ItemRepositoryInterface
let secondaryItemRepository: ItemRepositoryInterface | null
let logger: Logger
let primaryItem1: Item
let primaryItem2: Item
let secondaryItem1: Item
let secondaryItem2: Item
let timer: TimerInterface
const createUseCase = () =>
new TransitionItemsFromPrimaryToSecondaryDatabaseForUser(
primaryItemRepository,
secondaryItemRepository,
timer,
logger,
1,
)
beforeEach(() => {
primaryItem1 = Item.create(
{
duplicateOf: null,
itemsKeyId: 'items-key-id=1',
content: 'content-1',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: 'enc-item-key-1',
authHash: 'auth-hash-1',
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
deleted: false,
updatedWithSession: null,
dates: Dates.create(new Date(123), new Date(123)).getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
).getValue()
primaryItem2 = Item.create(
{
duplicateOf: null,
itemsKeyId: 'items-key-id=2',
content: 'content-2',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: 'enc-item-key-2',
authHash: 'auth-hash-2',
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
deleted: true,
updatedWithSession: null,
dates: Dates.create(new Date(123), new Date(123)).getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000001'),
).getValue()
secondaryItem1 = Item.create(
{
duplicateOf: null,
itemsKeyId: 'items-key-id=1',
content: 'content-1',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: 'enc-item-key-1',
authHash: 'auth-hash-1',
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
deleted: false,
updatedWithSession: null,
dates: Dates.create(new Date(123), new Date(123)).getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
).getValue()
secondaryItem2 = Item.create(
{
duplicateOf: null,
itemsKeyId: 'items-key-id=2',
content: 'content-2',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: 'enc-item-key-2',
authHash: 'auth-hash-2',
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
deleted: true,
updatedWithSession: null,
dates: Dates.create(new Date(123), new Date(123)).getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000001'),
).getValue()
primaryItemRepository = {} as jest.Mocked<ItemRepositoryInterface>
primaryItemRepository.countAll = jest.fn().mockResolvedValue(2)
primaryItemRepository.findAll = jest
.fn()
.mockResolvedValueOnce([primaryItem1])
.mockResolvedValueOnce([primaryItem2])
.mockResolvedValueOnce([primaryItem1])
.mockResolvedValueOnce([primaryItem2])
primaryItemRepository.deleteByUserUuid = jest.fn().mockResolvedValue(undefined)
secondaryItemRepository = {} as jest.Mocked<ItemRepositoryInterface>
secondaryItemRepository.save = jest.fn().mockResolvedValue(undefined)
secondaryItemRepository.deleteByUserUuid = jest.fn().mockResolvedValue(undefined)
secondaryItemRepository.countAll = jest.fn().mockReturnValueOnce(0).mockReturnValueOnce(2)
secondaryItemRepository.findByUuid = jest
.fn()
.mockResolvedValueOnce(secondaryItem1)
.mockResolvedValueOnce(secondaryItem2)
logger = {} as jest.Mocked<Logger>
logger.error = jest.fn()
logger.info = jest.fn()
timer = {} as jest.Mocked<TimerInterface>
timer.sleep = jest.fn()
timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123)
timer.convertMicrosecondsToTimeStructure = jest.fn().mockReturnValue({
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
milliseconds: 0,
})
})
describe('successfull transition', () => {
it('should transition items from primary to secondary database', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeFalsy()
expect(primaryItemRepository.countAll).toHaveBeenCalledTimes(3)
expect(primaryItemRepository.countAll).toHaveBeenCalledWith({ userUuid: '00000000-0000-0000-0000-000000000000' })
expect(primaryItemRepository.findAll).toHaveBeenCalledTimes(4)
expect(primaryItemRepository.findAll).toHaveBeenNthCalledWith(1, {
userUuid: '00000000-0000-0000-0000-000000000000',
limit: 1,
offset: 0,
})
expect(primaryItemRepository.findAll).toHaveBeenNthCalledWith(2, {
userUuid: '00000000-0000-0000-0000-000000000000',
limit: 1,
offset: 1,
})
expect(primaryItemRepository.findAll).toHaveBeenNthCalledWith(3, {
userUuid: '00000000-0000-0000-0000-000000000000',
limit: 1,
offset: 0,
})
expect(primaryItemRepository.findAll).toHaveBeenNthCalledWith(4, {
userUuid: '00000000-0000-0000-0000-000000000000',
limit: 1,
offset: 1,
})
expect((secondaryItemRepository as ItemRepositoryInterface).save).toHaveBeenCalledTimes(2)
expect((secondaryItemRepository as ItemRepositoryInterface).save).toHaveBeenCalledWith(primaryItem1)
expect((secondaryItemRepository as ItemRepositoryInterface).save).toHaveBeenCalledWith(primaryItem2)
expect((secondaryItemRepository as ItemRepositoryInterface).deleteByUserUuid).not.toHaveBeenCalled()
expect(primaryItemRepository.deleteByUserUuid).toHaveBeenCalledTimes(1)
})
it('should log an error if deleting items from primary database fails', async () => {
primaryItemRepository.deleteByUserUuid = jest.fn().mockRejectedValue(new Error('error'))
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeFalsy()
expect(logger.error).toHaveBeenCalledTimes(1)
expect(logger.error).toHaveBeenCalledWith(
'Failed to clean up primary database items for user 00000000-0000-0000-0000-000000000000: error',
)
})
})
describe('failed transition', () => {
it('should remove items from secondary database if integrity check fails', async () => {
const secondaryItem2WithDifferentContent = Item.create({
...secondaryItem2.props,
content: 'different-content',
}).getValue()
;(secondaryItemRepository as ItemRepositoryInterface).findByUuid = jest
.fn()
.mockResolvedValueOnce(secondaryItem1)
.mockResolvedValueOnce(secondaryItem2WithDifferentContent)
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
expect((secondaryItemRepository as ItemRepositoryInterface).deleteByUserUuid).toHaveBeenCalledTimes(1)
expect(primaryItemRepository.deleteByUserUuid).not.toHaveBeenCalled()
})
it('should remove items from secondary database if migrating items fails', async () => {
primaryItemRepository.findAll = jest
.fn()
.mockResolvedValueOnce([primaryItem1])
.mockRejectedValueOnce(new Error('error'))
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual('error')
expect((secondaryItemRepository as ItemRepositoryInterface).deleteByUserUuid).toHaveBeenCalledTimes(1)
expect(primaryItemRepository.deleteByUserUuid).not.toHaveBeenCalled()
})
it('should log an error if deleting items from secondary database fails upon migration failure', async () => {
primaryItemRepository.findAll = jest
.fn()
.mockResolvedValueOnce([primaryItem1])
.mockRejectedValueOnce(new Error('error'))
;(secondaryItemRepository as ItemRepositoryInterface).deleteByUserUuid = jest
.fn()
.mockRejectedValue(new Error('error'))
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
expect(logger.error).toHaveBeenCalledTimes(1)
expect(logger.error).toHaveBeenCalledWith(
'Failed to clean up secondary database items for user 00000000-0000-0000-0000-000000000000: error',
)
})
it('should log an error if deleting items from secondary database fails upon integrity check failure', async () => {
const secondaryItem2WithDifferentContent = Item.create({
...secondaryItem2.props,
content: 'different-content',
}).getValue()
;(secondaryItemRepository as ItemRepositoryInterface).findByUuid = jest
.fn()
.mockResolvedValueOnce(secondaryItem1)
.mockResolvedValueOnce(secondaryItem2WithDifferentContent)
;(secondaryItemRepository as ItemRepositoryInterface).deleteByUserUuid = jest
.fn()
.mockRejectedValue(new Error('error'))
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
expect(logger.error).toHaveBeenCalledTimes(1)
expect(logger.error).toHaveBeenCalledWith(
'Failed to clean up secondary database items for user 00000000-0000-0000-0000-000000000000: error',
)
})
it('should not perform the transition if secondary item repository is not set', async () => {
secondaryItemRepository = null
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual('Secondary item repository is not set')
expect(primaryItemRepository.countAll).not.toHaveBeenCalled()
expect(primaryItemRepository.findAll).not.toHaveBeenCalled()
expect(primaryItemRepository.deleteByUserUuid).not.toHaveBeenCalled()
})
it('should not perform the transition if the user uuid is invalid', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: 'invalid-uuid',
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual('Given value is not a valid uuid: invalid-uuid')
expect(primaryItemRepository.countAll).not.toHaveBeenCalled()
expect(primaryItemRepository.findAll).not.toHaveBeenCalled()
expect(primaryItemRepository.deleteByUserUuid).not.toHaveBeenCalled()
})
it('should fail integrity check if the item count is not the same in both databases', async () => {
;(secondaryItemRepository as ItemRepositoryInterface).countAll = jest
.fn()
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(1)
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual(
'Total items count for user 00000000-0000-0000-0000-000000000000 in primary database (2) does not match total items count in secondary database (1)',
)
expect(primaryItemRepository.countAll).toHaveBeenCalledTimes(3)
expect(primaryItemRepository.countAll).toHaveBeenCalledWith({ userUuid: '00000000-0000-0000-0000-000000000000' })
expect((secondaryItemRepository as ItemRepositoryInterface).countAll).toHaveBeenCalledTimes(2)
expect(primaryItemRepository.deleteByUserUuid).not.toHaveBeenCalled()
expect((secondaryItemRepository as ItemRepositoryInterface).deleteByUserUuid).toHaveBeenCalledTimes(1)
})
it('should fail if one item is not found in the secondary database', async () => {
;(secondaryItemRepository as ItemRepositoryInterface).findByUuid = jest
.fn()
.mockResolvedValueOnce(secondaryItem1)
.mockResolvedValueOnce(null)
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual('Item 00000000-0000-0000-0000-000000000001 not found in secondary database')
expect(primaryItemRepository.countAll).toHaveBeenCalledTimes(3)
expect(primaryItemRepository.countAll).toHaveBeenCalledWith({ userUuid: '00000000-0000-0000-0000-000000000000' })
expect((secondaryItemRepository as ItemRepositoryInterface).countAll).toHaveBeenCalledTimes(2)
expect(primaryItemRepository.deleteByUserUuid).not.toHaveBeenCalled()
expect((secondaryItemRepository as ItemRepositoryInterface).deleteByUserUuid).toHaveBeenCalledTimes(1)
})
it('should fail if an error is thrown during integrity check between primary and secondary database', async () => {
;(secondaryItemRepository as ItemRepositoryInterface).countAll = jest
.fn()
.mockReturnValueOnce(0)
.mockRejectedValue(new Error('error'))
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual('error')
expect(primaryItemRepository.deleteByUserUuid).not.toHaveBeenCalled()
expect((secondaryItemRepository as ItemRepositoryInterface).deleteByUserUuid).toHaveBeenCalledTimes(1)
})
})
it('should not migrate items if there are no items in the primary database', async () => {
primaryItemRepository.countAll = jest.fn().mockResolvedValue(0)
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeFalsy()
expect(primaryItemRepository.countAll).toHaveBeenCalledTimes(1)
expect(primaryItemRepository.countAll).toHaveBeenCalledWith({ userUuid: '00000000-0000-0000-0000-000000000000' })
expect(primaryItemRepository.findAll).not.toHaveBeenCalled()
expect((secondaryItemRepository as ItemRepositoryInterface).save).not.toHaveBeenCalled()
expect(primaryItemRepository.deleteByUserUuid).not.toHaveBeenCalled()
expect((secondaryItemRepository as ItemRepositoryInterface).deleteByUserUuid).not.toHaveBeenCalled()
})
it('should not migrate items if there are items in the secondary database', async () => {
;(secondaryItemRepository as ItemRepositoryInterface).countAll = jest.fn().mockResolvedValue(1)
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
expect(primaryItemRepository.findAll).not.toHaveBeenCalled()
expect((secondaryItemRepository as ItemRepositoryInterface).save).not.toHaveBeenCalled()
expect(primaryItemRepository.deleteByUserUuid).not.toHaveBeenCalled()
expect((secondaryItemRepository as ItemRepositoryInterface).deleteByUserUuid).not.toHaveBeenCalled()
})
})

View File

@@ -1,3 +1,4 @@
/* istanbul ignore file */
import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
import { Logger } from 'winston'
@@ -5,6 +6,7 @@ import { TransitionItemsFromPrimaryToSecondaryDatabaseForUserDTO } from './Trans
import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
import { ItemQuery } from '../../../Item/ItemQuery'
import { TimerInterface } from '@standardnotes/time'
import { Item } from '../../../Item/Item'
export class TransitionItemsFromPrimaryToSecondaryDatabaseForUser implements UseCaseInterface<void> {
constructor(
@@ -28,25 +30,48 @@ export class TransitionItemsFromPrimaryToSecondaryDatabaseForUser implements Use
}
const userUuid = userUuidOrError.getValue()
if (await this.isAlreadyMigrated(userUuid)) {
this.logger.info(`Items for user ${userUuid.value} are already migrated`)
return Result.ok()
}
let newItemsInSecondaryCount = 0
let updatedItemsInSecondary: Item[] = []
if (await this.hasAlreadyDataInSecondaryDatabase(userUuid)) {
return Result.fail(`Items for user ${userUuid.value} already exist in secondary database`)
const { alreadyExistingInPrimary, newItemsInSecondary, updatedInSecondary } =
await this.getNewItemsCreatedInSecondaryDatabase(userUuid)
for (const existingItem of alreadyExistingInPrimary) {
this.logger.info(`Removing item ${existingItem.uuid.value} from secondary database`)
await (this.secondaryItemRepository as ItemRepositoryInterface).remove(existingItem)
}
if (newItemsInSecondary.length > 0) {
this.logger.info(
`Found ${newItemsInSecondary.length} new items in secondary database for user ${userUuid.value}`,
)
}
newItemsInSecondaryCount = newItemsInSecondary.length
if (updatedInSecondary.length > 0) {
this.logger.info(
`Found ${updatedInSecondary.length} updated items in secondary database for user ${userUuid.value}`,
)
}
updatedItemsInSecondary = updatedInSecondary
}
const updatedItemsInSecondaryCount = updatedItemsInSecondary.length
await this.allowForSecondaryDatabaseToCatchUp()
const migrationTimeStart = this.timer.getTimestampInMicroseconds()
const migrationResult = await this.migrateItemsForUser(userUuid)
const migrationResult = await this.migrateItemsForUser(userUuid, updatedItemsInSecondary)
if (migrationResult.isFailed()) {
const cleanupResult = await this.deleteItemsForUser(userUuid, this.secondaryItemRepository)
if (cleanupResult.isFailed()) {
this.logger.error(
`Failed to clean up secondary database items for user ${userUuid.value}: ${cleanupResult.getError()}`,
)
if (newItemsInSecondaryCount === 0 && updatedItemsInSecondaryCount === 0) {
const cleanupResult = await this.deleteItemsForUser(userUuid, this.secondaryItemRepository)
if (cleanupResult.isFailed()) {
this.logger.error(
`Failed to clean up secondary database items for user ${userUuid.value}: ${cleanupResult.getError()}`,
)
}
}
return Result.fail(migrationResult.getError())
@@ -54,13 +79,18 @@ export class TransitionItemsFromPrimaryToSecondaryDatabaseForUser implements Use
await this.allowForSecondaryDatabaseToCatchUp()
const integrityCheckResult = await this.checkIntegrityBetweenPrimaryAndSecondaryDatabase(userUuid)
const integrityCheckResult = await this.checkIntegrityBetweenPrimaryAndSecondaryDatabase(
userUuid,
newItemsInSecondaryCount,
)
if (integrityCheckResult.isFailed()) {
const cleanupResult = await this.deleteItemsForUser(userUuid, this.secondaryItemRepository)
if (cleanupResult.isFailed()) {
this.logger.error(
`Failed to clean up secondary database items for user ${userUuid.value}: ${cleanupResult.getError()}`,
)
if (newItemsInSecondaryCount === 0 && updatedItemsInSecondaryCount === 0) {
const cleanupResult = await this.deleteItemsForUser(userUuid, this.secondaryItemRepository)
if (cleanupResult.isFailed()) {
this.logger.error(
`Failed to clean up secondary database items for user ${userUuid.value}: ${cleanupResult.getError()}`,
)
}
}
return Result.fail(integrityCheckResult.getError())
@@ -90,13 +120,12 @@ export class TransitionItemsFromPrimaryToSecondaryDatabaseForUser implements Use
userUuid: userUuid.value,
})
return totalItemsCountForUser > 0
}
const hasAlreadyDataInSecondaryDatabase = totalItemsCountForUser > 0
if (hasAlreadyDataInSecondaryDatabase) {
this.logger.info(`User ${userUuid.value} has already ${totalItemsCountForUser} items in secondary database`)
}
private async isAlreadyMigrated(userUuid: Uuid): Promise<boolean> {
const totalItemsCountForUser = await this.primaryItemRepository.countAll({ userUuid: userUuid.value })
return totalItemsCountForUser === 0
return hasAlreadyDataInSecondaryDatabase
}
private async allowForSecondaryDatabaseToCatchUp(): Promise<void> {
@@ -104,7 +133,68 @@ export class TransitionItemsFromPrimaryToSecondaryDatabaseForUser implements Use
await this.timer.sleep(twoSecondsInMilliseconds)
}
private async migrateItemsForUser(userUuid: Uuid): Promise<Result<void>> {
private async getNewItemsCreatedInSecondaryDatabase(userUuid: Uuid): Promise<{
alreadyExistingInPrimary: Item[]
newItemsInSecondary: Item[]
updatedInSecondary: Item[]
}> {
const items = await (this.secondaryItemRepository as ItemRepositoryInterface).findAll({
userUuid: userUuid.value,
})
const alreadyExistingInPrimary: Item[] = []
const updatedInSecondary: Item[] = []
const newItemsInSecondary: Item[] = []
for (const item of items) {
const { itemInPrimary, newerItemInSecondary } = await this.checkIfItemExistsInPrimaryDatabase(item)
if (itemInPrimary !== null) {
alreadyExistingInPrimary.push(item)
continue
}
if (newerItemInSecondary !== null) {
updatedInSecondary.push(newerItemInSecondary)
continue
}
if (itemInPrimary === null && newerItemInSecondary === null) {
newItemsInSecondary.push(item)
continue
}
}
return {
alreadyExistingInPrimary,
newItemsInSecondary,
updatedInSecondary,
}
}
private async checkIfItemExistsInPrimaryDatabase(
item: Item,
): Promise<{ itemInPrimary: Item | null; newerItemInSecondary: Item | null }> {
const itemInPrimary = await this.primaryItemRepository.findByUuid(item.uuid)
if (itemInPrimary === null) {
return { itemInPrimary: null, newerItemInSecondary: null }
}
if (!item.isIdenticalTo(itemInPrimary)) {
this.logger.error(
`Revision ${item.id.toString()} is not identical in primary and secondary database. Revision in secondary database: ${JSON.stringify(
item,
)}, revision in primary database: ${JSON.stringify(itemInPrimary)}`,
)
return {
itemInPrimary: null,
newerItemInSecondary: item.props.timestamps.updatedAt > itemInPrimary.props.timestamps.updatedAt ? item : null,
}
}
return { itemInPrimary: itemInPrimary, newerItemInSecondary: null }
}
private async migrateItemsForUser(userUuid: Uuid, updatedItemsInSecondary: Item[]): Promise<Result<void>> {
try {
const totalItemsCountForUser = await this.primaryItemRepository.countAll({ userUuid: userUuid.value })
const totalPages = Math.ceil(totalItemsCountForUser / this.pageSize)
@@ -118,6 +208,9 @@ export class TransitionItemsFromPrimaryToSecondaryDatabaseForUser implements Use
const items = await this.primaryItemRepository.findAll(query)
for (const item of items) {
if (updatedItemsInSecondary.find((updatedItem) => updatedItem.uuid.equals(item.uuid))) {
continue
}
await (this.secondaryItemRepository as ItemRepositoryInterface).save(item)
}
}
@@ -138,7 +231,10 @@ export class TransitionItemsFromPrimaryToSecondaryDatabaseForUser implements Use
}
}
private async checkIntegrityBetweenPrimaryAndSecondaryDatabase(userUuid: Uuid): Promise<Result<boolean>> {
private async checkIntegrityBetweenPrimaryAndSecondaryDatabase(
userUuid: Uuid,
newItemsInSecondaryCount: number,
): Promise<Result<boolean>> {
try {
const totalItemsCountForUserInPrimary = await this.primaryItemRepository.countAll({ userUuid: userUuid.value })
const totalItemsCountForUserInSecondary = await (
@@ -147,9 +243,9 @@ export class TransitionItemsFromPrimaryToSecondaryDatabaseForUser implements Use
userUuid: userUuid.value,
})
if (totalItemsCountForUserInPrimary !== totalItemsCountForUserInSecondary) {
if (totalItemsCountForUserInPrimary + newItemsInSecondaryCount !== totalItemsCountForUserInSecondary) {
return Result.fail(
`Total items count for user ${userUuid.value} in primary database (${totalItemsCountForUserInPrimary}) does not match total items count in secondary database (${totalItemsCountForUserInSecondary})`,
`Total items count for user ${userUuid.value} in primary database (${totalItemsCountForUserInPrimary} + ${newItemsInSecondaryCount}) does not match total items count in secondary database (${totalItemsCountForUserInSecondary})`,
)
}

View File

@@ -23,6 +23,7 @@ describe('TriggerTransitionFromPrimaryToSecondaryDatabaseForUser', () => {
await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
transitionTimestamp: 123,
})
expect(domainEventPubliser.publish).toHaveBeenCalled()

View File

@@ -15,6 +15,7 @@ export class TriggerTransitionFromPrimaryToSecondaryDatabaseForUser implements U
userUuid: dto.userUuid,
status: 'STARTED',
transitionType: 'items',
transitionTimestamp: dto.transitionTimestamp,
})
await this.domainEventPubliser.publish(event)

View File

@@ -1,3 +1,4 @@
export interface TriggerTransitionFromPrimaryToSecondaryDatabaseForUserDTO {
userUuid: string
transitionTimestamp: number
}

View File

@@ -11,7 +11,6 @@ import { SyncItems } from '../../Domain/UseCase/Syncing/SyncItems/SyncItems'
import { BaseItemsController } from './Base/BaseItemsController'
import { MapperInterface } from '@standardnotes/domain-core'
import { ItemHttpRepresentation } from '../../Mapping/Http/ItemHttpRepresentation'
import { TriggerTransitionFromPrimaryToSecondaryDatabaseForUser } from '../../Domain/UseCase/Transition/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser'
@controller('/items', TYPES.Sync_AuthMiddleware)
export class AnnotatedItemsController extends BaseItemsController {
@@ -19,20 +18,11 @@ export class AnnotatedItemsController extends BaseItemsController {
@inject(TYPES.Sync_SyncItems) override syncItems: SyncItems,
@inject(TYPES.Sync_CheckIntegrity) override checkIntegrity: CheckIntegrity,
@inject(TYPES.Sync_GetItem) override getItem: GetItem,
@inject(TYPES.Sync_TriggerTransitionFromPrimaryToSecondaryDatabaseForUser)
override triggerTransitionFromPrimaryToSecondaryDatabaseForUser: TriggerTransitionFromPrimaryToSecondaryDatabaseForUser,
@inject(TYPES.Sync_ItemHttpMapper) override itemHttpMapper: MapperInterface<Item, ItemHttpRepresentation>,
@inject(TYPES.Sync_SyncResponseFactoryResolver)
override syncResponseFactoryResolver: SyncResponseFactoryResolverInterface,
) {
super(
syncItems,
checkIntegrity,
getItem,
triggerTransitionFromPrimaryToSecondaryDatabaseForUser,
itemHttpMapper,
syncResponseFactoryResolver,
)
super(syncItems, checkIntegrity, getItem, itemHttpMapper, syncResponseFactoryResolver)
}
@httpPost('/sync')
@@ -45,11 +35,6 @@ export class AnnotatedItemsController extends BaseItemsController {
return super.checkItemsIntegrity(request, response)
}
@httpPost('/transition')
override async transition(request: Request, response: Response): Promise<results.JsonResult> {
return super.transition(request, response)
}
@httpGet('/:uuid')
override async getSingleItem(request: Request, response: Response): Promise<results.JsonResult> {
return super.getSingleItem(request, response)

View File

@@ -12,14 +12,12 @@ import { ApiVersion } from '../../../Domain/Api/ApiVersion'
import { SyncItems } from '../../../Domain/UseCase/Syncing/SyncItems/SyncItems'
import { ItemHttpRepresentation } from '../../../Mapping/Http/ItemHttpRepresentation'
import { ItemHash } from '../../../Domain/Item/ItemHash'
import { TriggerTransitionFromPrimaryToSecondaryDatabaseForUser } from '../../../Domain/UseCase/Transition/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser'
export class BaseItemsController extends BaseHttpController {
constructor(
protected syncItems: SyncItems,
protected checkIntegrity: CheckIntegrity,
protected getItem: GetItem,
protected triggerTransitionFromPrimaryToSecondaryDatabaseForUser: TriggerTransitionFromPrimaryToSecondaryDatabaseForUser,
protected itemHttpMapper: MapperInterface<Item, ItemHttpRepresentation>,
protected syncResponseFactoryResolver: SyncResponseFactoryResolverInterface,
private controllerContainer?: ControllerContainerInterface,
@@ -30,7 +28,6 @@ export class BaseItemsController extends BaseHttpController {
this.controllerContainer.register('sync.items.sync', this.sync.bind(this))
this.controllerContainer.register('sync.items.check_integrity', this.checkItemsIntegrity.bind(this))
this.controllerContainer.register('sync.items.get_item', this.getSingleItem.bind(this))
this.controllerContainer.register('sync.items.transition', this.transition.bind(this))
}
}
@@ -113,25 +110,6 @@ export class BaseItemsController extends BaseHttpController {
})
}
async transition(_request: Request, response: Response): Promise<results.JsonResult> {
const result = await this.triggerTransitionFromPrimaryToSecondaryDatabaseForUser.execute({
userUuid: response.locals.user.uuid,
})
if (result.isFailed()) {
return this.json(
{
error: { message: result.getError() },
},
400,
)
}
response.setHeader('x-invalidate-cache', response.locals.user.uuid)
return this.json({ success: true })
}
async getSingleItem(request: Request, response: Response): Promise<results.JsonResult> {
const result = await this.getItem.execute({
userUuid: response.locals.user.uuid,

View File

@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.10.41](https://github.com/standardnotes/server/compare/@standardnotes/websockets-server@1.10.40...@standardnotes/websockets-server@1.10.41) (2023-09-13)
**Note:** Version bump only for package @standardnotes/websockets-server
## [1.10.40](https://github.com/standardnotes/server/compare/@standardnotes/websockets-server@1.10.39...@standardnotes/websockets-server@1.10.40) (2023-09-12)
**Note:** Version bump only for package @standardnotes/websockets-server

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/websockets-server",
"version": "1.10.40",
"version": "1.10.41",
"engines": {
"node": ">=18.0.0 <21.0.0"
},