Compare commits

...

24 Commits

Author SHA1 Message Date
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
standardci
4f95bbee3f chore(release): publish new version
- @standardnotes/analytics@2.26.11
 - @standardnotes/api-gateway@1.74.8
 - @standardnotes/auth-server@1.141.7
 - @standardnotes/domain-events-infra@1.12.27
 - @standardnotes/domain-events@2.125.3
 - @standardnotes/event-store@1.11.39
 - @standardnotes/files-server@1.22.18
 - @standardnotes/home-server@1.15.43
 - @standardnotes/revisions-server@1.33.13
 - @standardnotes/scheduler-server@1.20.43
 - @standardnotes/syncing-server@1.95.10
 - @standardnotes/websockets-server@1.10.40
2023-09-12 13:37:37 +00:00
Karol Sójko
b9c9f74d0c fix(syncing-server): log syncing errors 2023-09-12 15:03:11 +02:00
Karol Sójko
e535cd504c fix: retry failed revision transitions 2023-09-12 14:45:17 +02:00
standardci
db0360860a chore(release): publish new version
- @standardnotes/home-server@1.15.42
 - @standardnotes/revisions-server@1.33.12
 - @standardnotes/syncing-server@1.95.9
2023-09-12 10:57:29 +00:00
Karol Sójko
aa2b5f3b74 chore: add logs for failed item dumps 2023-09-12 12:13:55 +02:00
standardci
6241661e27 chore(release): publish new version
- @standardnotes/home-server@1.15.41
 - @standardnotes/syncing-server@1.95.8
2023-09-12 09:38:19 +00:00
Karol Sójko
25047bf46d fix(syncing-server): allow fetching shared vault users for members (#821) 2023-09-12 11:00:55 +02:00
61 changed files with 885 additions and 1073 deletions

View File

@@ -53,7 +53,7 @@ services:
image: mysql:8
container_name: db-ci
env_file: .github/ci.env
expose:
ports:
- 3306
restart: unless-stopped
command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci
@@ -66,7 +66,7 @@ services:
secondary_db:
image: mongo:5.0
container_name: secondary_db-ci
expose:
ports:
- 27017
restart: unless-stopped
volumes:
@@ -83,7 +83,7 @@ services:
container_name: cache-ci
volumes:
- ./data/redis/:/data
expose:
ports:
- 6379
restart: unless-stopped
networks:

View File

@@ -3,6 +3,14 @@
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
## [2.26.10](https://github.com/standardnotes/server/compare/@standardnotes/analytics@2.26.9...@standardnotes/analytics@2.26.10) (2023-09-12)
**Note:** Version bump only for package @standardnotes/analytics

View File

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

View File

@@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [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
## [1.74.7](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.74.6...@standardnotes/api-gateway@1.74.7) (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.7",
"version": "1.74.9",
"engines": {
"node": ">=18.0.0 <21.0.0"
},

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.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
* retry failed revision transitions ([e535cd5](https://github.com/standardnotes/server/commit/e535cd504cf1929539ff7faf13e9c1fdd2b7bfd1))
## [1.141.6](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.141.5...@standardnotes/auth-server@1.141.6) (2023-09-12)
**Note:** Version bump only for package @standardnotes/auth-server

View File

@@ -10,7 +10,7 @@ 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)
const startDateString = inputArgs[0]
@@ -18,6 +18,7 @@ const endDateString = inputArgs[1]
const requestTransition = async (
userRepository: UserRepositoryInterface,
transitionStatusRepository: TransitionStatusRepositoryInterface,
logger: Logger,
domainEventFactory: DomainEventFactoryInterface,
domainEventPublisher: DomainEventPublisherInterface,
@@ -31,13 +32,10 @@ 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 })
const transitionRequestedEvent = domainEventFactory.createTransitionRequestedEvent({
userUuid: user.uuid,
type: 'items',
})
usersTriggered += 1
@@ -47,6 +45,20 @@ const requestTransition = async (
logger.info(
`Triggered transition for ${usersTriggered} users created between ${startDateString} and ${endDateString}`,
)
const revisionStatuses = await transitionStatusRepository.getStatuses('revisions')
const failedStatuses = revisionStatuses.filter((status) => status.status === 'FAILED')
logger.info(`Found ${failedStatuses.length} failed revision transitions`)
for (const status of failedStatuses) {
const transitionRequestedEvent = domainEventFactory.createTransitionRequestedEvent({
userUuid: status.userUuid,
type: 'revisions',
})
await domainEventPublisher.publish(transitionRequestedEvent)
}
}
const container = new ContainerConfigLoader('worker')
@@ -63,8 +75,13 @@ void container.load().then((container) => {
const userRepository: UserRepositoryInterface = container.get(TYPES.Auth_UserRepository)
const domainEventFactory: DomainEventFactoryInterface = container.get(TYPES.Auth_DomainEventFactory)
const domainEventPublisher: DomainEventPublisherInterface = container.get(TYPES.Auth_DomainEventPublisher)
const transitionStatusRepository: TransitionStatusRepositoryInterface = container.get(
TYPES.Auth_TransitionStatusRepository,
)
Promise.resolve(requestTransition(userRepository, logger, domainEventFactory, domainEventPublisher))
Promise.resolve(
requestTransition(userRepository, transitionStatusRepository, logger, domainEventFactory, domainEventPublisher),
)
.then(() => {
logger.info(`Finished transition request for users created between ${startDateString} and ${endDateString}`)

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/auth-server",
"version": "1.141.6",
"version": "1.141.9",
"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

View File

@@ -33,7 +33,7 @@ import { DomainEventFactoryInterface } from './DomainEventFactoryInterface'
export class DomainEventFactory implements DomainEventFactoryInterface {
constructor(@inject(TYPES.Auth_Timer) private timer: TimerInterface) {}
createTransitionRequestedEvent(dto: { userUuid: string }): TransitionRequestedEvent {
createTransitionRequestedEvent(dto: { userUuid: string; type: 'items' | 'revisions' }): TransitionRequestedEvent {
return {
type: 'TRANSITION_REQUESTED',
createdAt: this.timer.getUTCDate(),
@@ -44,7 +44,10 @@ export class DomainEventFactory implements DomainEventFactoryInterface {
},
origin: DomainEventService.Auth,
},
payload: dto,
payload: {
timestamp: this.timer.getTimestampInMicroseconds(),
...dto,
},
}
}

View File

@@ -90,5 +90,5 @@ export interface DomainEventFactoryInterface {
}): StatisticPersistenceRequestedEvent
createSessionCreatedEvent(dto: { userUuid: string }): SessionCreatedEvent
createSessionRefreshedEvent(dto: { userUuid: string }): SessionRefreshedEvent
createTransitionRequestedEvent(dto: { userUuid: string }): TransitionRequestedEvent
createTransitionRequestedEvent(dto: { userUuid: string; type: 'items' | 'revisions' }): TransitionRequestedEvent
}

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

@@ -9,4 +9,7 @@ export interface TransitionStatusRepositoryInterface {
userUuid: string,
transitionType: 'items' | 'revisions',
): Promise<'STARTED' | 'IN_PROGRESS' | 'FAILED' | null>
getStatuses(
transitionType: 'items' | 'revisions',
): Promise<Array<{ userUuid: string; status: 'STARTED' | 'IN_PROGRESS' | 'FAILED' }>>
}

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

@@ -4,6 +4,24 @@ export class InMemoryTransitionStatusRepository implements TransitionStatusRepos
private itemStatuses: Map<string, 'STARTED' | 'FAILED'> = new Map()
private revisionStatuses: Map<string, 'STARTED' | 'FAILED'> = new Map()
async getStatuses(
transitionType: 'items' | 'revisions',
): Promise<{ userUuid: string; status: 'STARTED' | 'FAILED' | 'IN_PROGRESS' }[]> {
const statuses: { userUuid: string; status: 'STARTED' | 'FAILED' | 'IN_PROGRESS' }[] = []
if (transitionType === 'items') {
for (const [userUuid, status] of this.itemStatuses) {
statuses.push({ userUuid, status })
}
} else {
for (const [userUuid, status] of this.revisionStatuses) {
statuses.push({ userUuid, status })
}
}
return statuses
}
async updateStatus(
userUuid: string,
transitionType: 'items' | 'revisions',

View File

@@ -7,6 +7,21 @@ export class RedisTransitionStatusRepository implements TransitionStatusReposito
constructor(private redisClient: IORedis.Redis) {}
async getStatuses(
transitionType: 'items' | 'revisions',
): Promise<{ userUuid: string; status: 'STARTED' | 'IN_PROGRESS' | 'FAILED' }[]> {
const keys = await this.redisClient.keys(`${this.PREFIX}:${transitionType}:*`)
const statuses = await Promise.all(
keys.map(async (key) => {
const userUuid = key.split(':')[2]
const status = (await this.redisClient.get(key)) as 'STARTED' | 'IN_PROGRESS' | 'FAILED'
return { userUuid, status }
}),
)
return statuses
}
async updateStatus(
userUuid: string,
transitionType: 'items' | 'revisions',

View File

@@ -3,6 +3,14 @@
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
## [1.12.26](https://github.com/standardnotes/server/compare/@standardnotes/domain-events-infra@1.12.25...@standardnotes/domain-events-infra@1.12.26) (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.26",
"version": "1.12.28",
"engines": {
"node": ">=18.0.0 <21.0.0"
},

View File

@@ -3,6 +3,18 @@
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
* retry failed revision transitions ([e535cd5](https://github.com/standardnotes/server/commit/e535cd504cf1929539ff7faf13e9c1fdd2b7bfd1))
## [2.125.2](https://github.com/standardnotes/server/compare/@standardnotes/domain-events@2.125.1...@standardnotes/domain-events@2.125.2) (2023-09-12)
### Bug Fixes

View File

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

View File

@@ -1,3 +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,14 @@
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
## [1.11.38](https://github.com/standardnotes/server/compare/@standardnotes/event-store@1.11.37...@standardnotes/event-store@1.11.38) (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.38",
"version": "1.11.40",
"description": "Event Store Service",
"private": true,
"main": "dist/src/index.js",

View File

@@ -3,6 +3,14 @@
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
## [1.22.17](https://github.com/standardnotes/files/compare/@standardnotes/files-server@1.22.16...@standardnotes/files-server@1.22.17) (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.17",
"version": "1.22.19",
"engines": {
"node": ">=18.0.0 <21.0.0"
},

View File

@@ -3,6 +3,42 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [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
## [1.15.42](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.15.41...@standardnotes/home-server@1.15.42) (2023-09-12)
**Note:** Version bump only for package @standardnotes/home-server
## [1.15.41](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.15.40...@standardnotes/home-server@1.15.41) (2023-09-12)
**Note:** Version bump only for package @standardnotes/home-server
## [1.15.40](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.15.39...@standardnotes/home-server@1.15.40) (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.40",
"version": "1.15.49",
"engines": {
"node": ">=18.0.0 <21.0.0"
},

View File

@@ -3,6 +3,46 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [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
* retry failed revision transitions ([e535cd5](https://github.com/standardnotes/server/commit/e535cd504cf1929539ff7faf13e9c1fdd2b7bfd1))
## [1.33.12](https://github.com/standardnotes/server/compare/@standardnotes/revisions-server@1.33.11...@standardnotes/revisions-server@1.33.12) (2023-09-12)
**Note:** Version bump only for package @standardnotes/revisions-server
## [1.33.11](https://github.com/standardnotes/server/compare/@standardnotes/revisions-server@1.33.10...@standardnotes/revisions-server@1.33.11) (2023-09-12)
### Bug Fixes

View File

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

View File

@@ -68,6 +68,7 @@ import { SQLRevisionMetadataPersistenceMapper } from '../Mapping/Persistence/SQL
import { SQLRevisionPersistenceMapper } from '../Mapping/Persistence/SQL/SQLRevisionPersistenceMapper'
import { RemoveRevisionsFromSharedVault } from '../Domain/UseCase/RemoveRevisionsFromSharedVault/RemoveRevisionsFromSharedVault'
import { ItemRemovedFromSharedVaultEventHandler } from '../Domain/Handler/ItemRemovedFromSharedVaultEventHandler'
import { TransitionRequestedEventHandler } from '../Domain/Handler/TransitionRequestedEventHandler'
export class ContainerConfigLoader {
constructor(private mode: 'server' | 'worker' = 'server') {}
@@ -419,6 +420,7 @@ export class ContainerConfigLoader {
new ItemDumpedEventHandler(
container.get<DumpRepositoryInterface>(TYPES.Revisions_DumpRepository),
container.get<RevisionRepositoryResolverInterface>(TYPES.Revisions_RevisionRepositoryResolver),
container.get<winston.Logger>(TYPES.Revisions_Logger),
),
)
container
@@ -441,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,
),
@@ -457,6 +460,16 @@ export class ContainerConfigLoader {
container.get<winston.Logger>(TYPES.Revisions_Logger),
),
)
container
.bind<TransitionRequestedEventHandler>(TYPES.Revisions_TransitionRequestedEventHandler)
.toConstantValue(
new TransitionRequestedEventHandler(
container.get<TriggerTransitionFromPrimaryToSecondaryDatabaseForUser>(
TYPES.Revisions_TriggerTransitionFromPrimaryToSecondaryDatabaseForUser,
),
container.get<winston.Logger>(TYPES.Revisions_Logger),
),
)
const eventHandlers: Map<string, DomainEventHandlerInterface> = new Map([
['ITEM_DUMPED', container.get(TYPES.Revisions_ItemDumpedEventHandler)],
@@ -464,6 +477,7 @@ export class ContainerConfigLoader {
['REVISIONS_COPY_REQUESTED', container.get(TYPES.Revisions_RevisionsCopyRequestedEventHandler)],
['TRANSITION_STATUS_UPDATED', container.get(TYPES.Revisions_TransitionStatusUpdatedEventHandler)],
['ITEM_REMOVED_FROM_SHARED_VAULT', container.get(TYPES.Revisions_ItemRemovedFromSharedVaultEventHandler)],
['TRANSITION_REQUESTED', container.get(TYPES.Revisions_TransitionRequestedEventHandler)],
])
if (isConfiguredForHomeServer) {

View File

@@ -60,6 +60,7 @@ const TYPES = {
Revisions_RevisionsCopyRequestedEventHandler: Symbol.for('Revisions_RevisionsCopyRequestedEventHandler'),
Revisions_TransitionStatusUpdatedEventHandler: Symbol.for('Revisions_TransitionStatusUpdatedEventHandler'),
Revisions_ItemRemovedFromSharedVaultEventHandler: Symbol.for('Revisions_ItemRemovedFromSharedVaultEventHandler'),
Revisions_TransitionRequestedEventHandler: Symbol.for('Revisions_TransitionRequestedEventHandler'),
// Services
Revisions_CrossServiceTokenDecoder: Symbol.for('Revisions_CrossServiceTokenDecoder'),
Revisions_DomainEventSubscriberFactory: Symbol.for('Revisions_DomainEventSubscriberFactory'),

View File

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

View File

@@ -1,4 +1,7 @@
import { ItemDumpedEvent } from '@standardnotes/domain-events'
import { Logger } from 'winston'
import { Uuid, ContentType, Dates } from '@standardnotes/domain-core'
import { DumpRepositoryInterface } from '../Dump/DumpRepositoryInterface'
import { Revision } from '../Revision/Revision'
import { RevisionRepositoryInterface } from '../Revision/RevisionRepositoryInterface'
@@ -11,11 +14,22 @@ describe('ItemDumpedEventHandler', () => {
let revisionRepositoryResolver: RevisionRepositoryResolverInterface
let revision: Revision
let event: ItemDumpedEvent
let logger: Logger
const createHandler = () => new ItemDumpedEventHandler(dumpRepository, revisionRepositoryResolver)
const createHandler = () => new ItemDumpedEventHandler(dumpRepository, revisionRepositoryResolver, logger)
beforeEach(() => {
revision = {} as jest.Mocked<Revision>
revision = 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(),
}).getValue()
dumpRepository = {} as jest.Mocked<DumpRepositoryInterface>
dumpRepository.getRevisionFromDumpPath = jest.fn().mockReturnValue(revision)
@@ -32,6 +46,10 @@ describe('ItemDumpedEventHandler', () => {
fileDumpPath: 'foobar',
roleNames: ['CORE_USER'],
}
logger = {} as jest.Mocked<Logger>
logger.debug = jest.fn()
logger.error = jest.fn()
})
it('should save a revision from file dump', async () => {

View File

@@ -3,16 +3,20 @@ import { DomainEventHandlerInterface, ItemDumpedEvent } from '@standardnotes/dom
import { DumpRepositoryInterface } from '../Dump/DumpRepositoryInterface'
import { RevisionRepositoryResolverInterface } from '../Revision/RevisionRepositoryResolverInterface'
import { RoleNameCollection } from '@standardnotes/domain-core'
import { Logger } from 'winston'
export class ItemDumpedEventHandler implements DomainEventHandlerInterface {
constructor(
private dumpRepository: DumpRepositoryInterface,
private revisionRepositoryResolver: RevisionRepositoryResolverInterface,
private logger: Logger,
) {}
async handle(event: ItemDumpedEvent): Promise<void> {
const revision = await this.dumpRepository.getRevisionFromDumpPath(event.payload.fileDumpPath)
if (revision === null) {
this.logger.error(`Revision not found for dump path ${event.payload.fileDumpPath}`)
await this.dumpRepository.removeDump(event.payload.fileDumpPath)
return
@@ -20,6 +24,8 @@ export class ItemDumpedEventHandler implements DomainEventHandlerInterface {
const roleNamesOrError = RoleNameCollection.create(event.payload.roleNames)
if (roleNamesOrError.isFailed()) {
this.logger.error(`Invalid role names ${event.payload.roleNames}`)
await this.dumpRepository.removeDump(event.payload.fileDumpPath)
return
@@ -28,7 +34,10 @@ export class ItemDumpedEventHandler implements DomainEventHandlerInterface {
const revisionRepository = this.revisionRepositoryResolver.resolve(roleNames)
await revisionRepository.insert(revision)
const successfullyInserted = await revisionRepository.insert(revision)
if (!successfullyInserted) {
this.logger.error(`Could not insert revision ${revision.id.toString()}`)
}
await this.dumpRepository.removeDump(event.payload.fileDumpPath)
}

View File

@@ -0,0 +1,27 @@
import { DomainEventHandlerInterface, TransitionRequestedEvent } from '@standardnotes/domain-events'
import { Logger } from 'winston'
import { TriggerTransitionFromPrimaryToSecondaryDatabaseForUser } from '../UseCase/Transition/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser'
export class TransitionRequestedEventHandler implements DomainEventHandlerInterface {
constructor(
private triggerTransitionFromPrimaryToSecondaryDatabaseForUser: TriggerTransitionFromPrimaryToSecondaryDatabaseForUser,
private logger: Logger,
) {}
async handle(event: TransitionRequestedEvent): Promise<void> {
if (event.payload.type !== 'revisions') {
return
}
this.logger.info(`Handling transition requested event for user ${event.payload.userUuid}`)
const result = await this.triggerTransitionFromPrimaryToSecondaryDatabaseForUser.execute({
userUuid: event.payload.userUuid,
})
if (result.isFailed()) {
this.logger.error(`Failed to trigger transition for user ${event.payload.userUuid}`)
}
}
}

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,
@@ -29,6 +32,34 @@ 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',
}),
)
return
}
if (await this.isAlreadyMigrated(userUuidOrError.getValue())) {
await this.domainEventPublisher.publish(
this.domainEventFactory.createTransitionStatusUpdatedEvent({
userUuid: event.payload.userUuid,
status: 'FINISHED',
transitionType: 'revisions',
}),
)
return
}
await this.domainEventPublisher.publish(
this.domainEventFactory.createTransitionStatusUpdatedEvent({
userUuid: event.payload.userUuid,
@@ -66,4 +97,16 @@ export class TransitionStatusUpdatedEventHandler implements DomainEventHandlerIn
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,41 @@ 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
if (await this.hasAlreadyDataInSecondaryDatabase(userUuid)) {
return Result.fail(`Revisions for user ${userUuid.value} already exist in secondary database`)
const newRevisions = await this.getNewRevisionsCreatedInSecondaryDatabase(userUuid)
for (const existingRevision of newRevisions.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 (newRevisions.newRevisionsInSecondary.length > 0) {
this.logger.info(
`Found ${newRevisions.newRevisionsInSecondary.length} new revisions in secondary database for user ${userUuid.value}`,
)
}
newRevisionsInSecondaryCount = newRevisions.newRevisionsInSecondary.length
}
await this.allowForSecondaryDatabaseToCatchUp()
const migrationTimeStart = this.timer.getTimestampInMicroseconds()
this.logger.debug(`Transitioning revisions for user ${userUuid.value}`)
const migrationResult = await this.migrateRevisionsForUser(userUuid)
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) {
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 +71,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) {
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())
@@ -154,16 +175,70 @@ 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[]
}> {
const revisions = await (this.secondRevisionsRepository as RevisionRepositoryInterface).findByUserUuid({
userUuid: userUuid,
})
return totalRevisionsCountForUserInPrimary === 0
const alreadyExistingInPrimary: Revision[] = []
const newRevisionsInSecondary: Revision[] = []
for (const revision of revisions) {
const revisionExistsInPrimary = await this.checkIfRevisionExistsInPrimaryDatabase(revision)
if (revisionExistsInPrimary) {
alreadyExistingInPrimary.push(revision)
} else {
newRevisionsInSecondary.push(revision)
}
}
return {
alreadyExistingInPrimary: alreadyExistingInPrimary,
newRevisionsInSecondary: newRevisionsInSecondary,
}
}
private async checkIntegrityBetweenPrimaryAndSecondaryDatabase(userUuid: Uuid): Promise<Result<boolean>> {
private async checkIfRevisionExistsInPrimaryDatabase(revision: Revision): Promise<boolean> {
const revisionInPrimary = await this.primaryRevisionsRepository.findOneByUuid(
Uuid.create(revision.id.toString()).getValue(),
revision.props.userUuid as Uuid,
[],
)
if (revisionInPrimary === null) {
return false
}
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 false
}
return true
}
private async checkIntegrityBetweenPrimaryAndSecondaryDatabase(
userUuid: Uuid,
newRevisionsInSecondaryCount: number,
): Promise<Result<boolean>> {
try {
const totalRevisionsCountForUserInPrimary = await this.primaryRevisionsRepository.countByUserUuid(userUuid)
@@ -206,9 +281,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

@@ -3,6 +3,14 @@
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
## [1.20.42](https://github.com/standardnotes/server/compare/@standardnotes/scheduler-server@1.20.41...@standardnotes/scheduler-server@1.20.42) (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.42",
"version": "1.20.44",
"engines": {
"node": ">=18.0.0 <21.0.0"
},

View File

@@ -3,6 +3,55 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [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
* retry failed revision transitions ([e535cd5](https://github.com/standardnotes/syncing-server-js/commit/e535cd504cf1929539ff7faf13e9c1fdd2b7bfd1))
* **syncing-server:** log syncing errors ([b9c9f74](https://github.com/standardnotes/syncing-server-js/commit/b9c9f74d0c699cf72ea6090627bd5716ac8360d7))
## [1.95.9](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.95.8...@standardnotes/syncing-server@1.95.9) (2023-09-12)
**Note:** Version bump only for package @standardnotes/syncing-server
## [1.95.8](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.95.7...@standardnotes/syncing-server@1.95.8) (2023-09-12)
### Bug Fixes
* **syncing-server:** allow fetching shared vault users for members ([#821](https://github.com/standardnotes/syncing-server-js/issues/821)) ([25047bf](https://github.com/standardnotes/syncing-server-js/commit/25047bf46dfabba7b12eafb59519de3f08822e45))
## [1.95.7](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.95.6...@standardnotes/syncing-server@1.95.7) (2023-09-12)
### Bug Fixes

View File

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

View File

@@ -666,13 +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<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) => {
@@ -951,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,
),

View File

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

View File

@@ -10,6 +10,10 @@ export class TransitionRequestedEventHandler implements DomainEventHandlerInterf
) {}
async handle(event: TransitionRequestedEvent): Promise<void> {
if (event.payload.type !== 'items') {
return
}
this.logger.info(`Handling transition requested event for user ${event.payload.userUuid}`)
const result = await this.triggerTransitionFromPrimaryToSecondaryDatabaseForUser.execute({

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,6 +19,18 @@ 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',
}),
)
return
}
await this.domainEventPublisher.publish(
this.domainEventFactory.createTransitionStatusUpdatedEvent({
userUuid: event.payload.userUuid,
@@ -52,4 +66,14 @@ export class TransitionStatusUpdatedEventHandler implements DomainEventHandlerIn
)
}
}
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

@@ -58,13 +58,8 @@ describe('GetSharedVaultUsers', () => {
expect(result.getError()).toBe('Shared vault not found')
})
it('returns error when originator is not the owner of the shared vault', async () => {
sharedVault = SharedVault.create({
fileUploadBytesUsed: 2,
userUuid: Uuid.create('00000000-0000-0000-0000-000000000001').getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
}).getValue()
sharedVaultRepository.findByUuid = jest.fn().mockResolvedValue(sharedVault)
it('returns error when originator is not a member of the shared vault', async () => {
sharedVaultUsersRepository.findBySharedVaultUuid = jest.fn().mockResolvedValue([])
const useCase = createUseCase()
const result = await useCase.execute({
@@ -73,7 +68,7 @@ describe('GetSharedVaultUsers', () => {
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Only the owner can get shared vault users')
expect(result.getError()).toBe('Originator is not a member of the shared vault')
})
it('returns error when shared vault uuid is invalid', async () => {

View File

@@ -28,13 +28,15 @@ export class GetSharedVaultUsers implements UseCaseInterface<SharedVaultUser[]>
return Result.fail('Shared vault not found')
}
const isOriginatorTheOwnerOfTheSharedVault = sharedVault.props.userUuid.equals(originatorUuid)
if (!isOriginatorTheOwnerOfTheSharedVault) {
return Result.fail('Only the owner can get shared vault users')
}
const sharedVaultUsers = await this.sharedVaultUsersRepository.findBySharedVaultUuid(sharedVaultUuid)
const isOriginatorAMember = sharedVaultUsers.some((sharedVaultUser) =>
sharedVaultUser.props.userUuid.equals(originatorUuid),
)
if (!isOriginatorAMember) {
return Result.fail('Originator is not a member of the shared vault')
}
return Result.ok(sharedVaultUsers)
}
}

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

@@ -14,6 +14,7 @@ import { GetMessagesSentToUser } from '../../Messaging/GetMessagesSentToUser/Get
import { GetUserNotifications } from '../../Messaging/GetUserNotifications/GetUserNotifications'
import { GetSharedVaultInvitesSentToUser } from '../../SharedVaults/GetSharedVaultInvitesSentToUser/GetSharedVaultInvitesSentToUser'
import { ItemRepositoryResolverInterface } from '../../../Item/ItemRepositoryResolverInterface'
import { Logger } from 'winston'
describe('SyncItems', () => {
let getItemsUseCase: GetItems
@@ -28,6 +29,7 @@ describe('SyncItems', () => {
let getSharedVaultInvitesSentToUserUseCase: GetSharedVaultInvitesSentToUser
let getMessagesSentToUser: GetMessagesSentToUser
let getUserNotifications: GetUserNotifications
let logger: Logger
const createUseCase = () =>
new SyncItems(
@@ -38,9 +40,15 @@ describe('SyncItems', () => {
getSharedVaultInvitesSentToUserUseCase,
getMessagesSentToUser,
getUserNotifications,
logger,
)
beforeEach(() => {
logger = {} as jest.Mocked<Logger>
logger.info = jest.fn()
logger.debug = jest.fn()
logger.error = jest.fn()
item1 = Item.create(
{
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
@@ -188,6 +196,35 @@ describe('SyncItems', () => {
})
})
it('should log error if sync items throws an error', async () => {
getItemsUseCase.execute = jest.fn().mockImplementation(() => {
throw new Error('error')
})
let caughtError = null
try {
await createUseCase().execute({
userUuid: '1-2-3',
onGoingRevisionsTransition: false,
itemHashes: [itemHash],
computeIntegrityHash: false,
syncToken: 'foo',
cursorToken: 'bar',
limit: 10,
readOnlyAccess: false,
contentType: 'Note',
apiVersion: ApiVersion.v20200115,
sessionUuid: null,
snjsVersion: '1.2.3',
roleNames: [RoleName.NAMES.CoreUser],
})
} catch (error) {
caughtError = error
}
expect(caughtError).not.toBeNull()
})
it('should sync items and return items keys on top for first sync that is not a shared vault exclusive sync', async () => {
const result = await createUseCase().execute({
userUuid: '1-2-3',

View File

@@ -11,6 +11,7 @@ import { GetSharedVaultInvitesSentToUser } from '../../SharedVaults/GetSharedVau
import { GetMessagesSentToUser } from '../../Messaging/GetMessagesSentToUser/GetMessagesSentToUser'
import { GetUserNotifications } from '../../Messaging/GetUserNotifications/GetUserNotifications'
import { ItemRepositoryResolverInterface } from '../../../Item/ItemRepositoryResolverInterface'
import { Logger } from 'winston'
export class SyncItems implements UseCaseInterface<SyncItemsResponse> {
constructor(
@@ -21,99 +22,111 @@ export class SyncItems implements UseCaseInterface<SyncItemsResponse> {
private getSharedVaultInvitesSentToUserUseCase: GetSharedVaultInvitesSentToUser,
private getMessagesSentToUser: GetMessagesSentToUser,
private getUserNotifications: GetUserNotifications,
private logger: Logger,
) {}
async execute(dto: SyncItemsDTO): Promise<Result<SyncItemsResponse>> {
const roleNamesOrError = RoleNameCollection.create(dto.roleNames)
if (roleNamesOrError.isFailed()) {
return Result.fail(roleNamesOrError.getError())
}
const roleNames = roleNamesOrError.getValue()
try {
const roleNamesOrError = RoleNameCollection.create(dto.roleNames)
if (roleNamesOrError.isFailed()) {
return Result.fail(roleNamesOrError.getError())
}
const roleNames = roleNamesOrError.getValue()
const getItemsResultOrError = await this.getItemsUseCase.execute({
userUuid: dto.userUuid,
syncToken: dto.syncToken,
cursorToken: dto.cursorToken,
limit: dto.limit,
contentType: dto.contentType,
sharedVaultUuids: dto.sharedVaultUuids,
roleNames: dto.roleNames,
})
if (getItemsResultOrError.isFailed()) {
return Result.fail(getItemsResultOrError.getError())
}
const getItemsResult = getItemsResultOrError.getValue()
const getItemsResultOrError = await this.getItemsUseCase.execute({
userUuid: dto.userUuid,
syncToken: dto.syncToken,
cursorToken: dto.cursorToken,
limit: dto.limit,
contentType: dto.contentType,
sharedVaultUuids: dto.sharedVaultUuids,
roleNames: dto.roleNames,
})
if (getItemsResultOrError.isFailed()) {
return Result.fail(getItemsResultOrError.getError())
}
const getItemsResult = getItemsResultOrError.getValue()
const saveItemsResultOrError = await this.saveItemsUseCase.execute({
itemHashes: dto.itemHashes,
userUuid: dto.userUuid,
apiVersion: dto.apiVersion,
readOnlyAccess: dto.readOnlyAccess,
sessionUuid: dto.sessionUuid,
snjsVersion: dto.snjsVersion,
roleNames: dto.roleNames,
onGoingRevisionsTransition: dto.onGoingRevisionsTransition,
})
if (saveItemsResultOrError.isFailed()) {
return Result.fail(saveItemsResultOrError.getError())
}
const saveItemsResult = saveItemsResultOrError.getValue()
const saveItemsResultOrError = await this.saveItemsUseCase.execute({
itemHashes: dto.itemHashes,
userUuid: dto.userUuid,
apiVersion: dto.apiVersion,
readOnlyAccess: dto.readOnlyAccess,
sessionUuid: dto.sessionUuid,
snjsVersion: dto.snjsVersion,
roleNames: dto.roleNames,
onGoingRevisionsTransition: dto.onGoingRevisionsTransition,
})
if (saveItemsResultOrError.isFailed()) {
return Result.fail(saveItemsResultOrError.getError())
}
const saveItemsResult = saveItemsResultOrError.getValue()
let retrievedItems = this.filterOutSyncConflictsForConsecutiveSyncs(getItemsResult.items, saveItemsResult.conflicts)
const isSharedVaultExclusiveSync = dto.sharedVaultUuids && dto.sharedVaultUuids.length > 0
if (this.isFirstSync(dto) && !isSharedVaultExclusiveSync) {
retrievedItems = await this.frontLoadKeysItemsToTop(dto.userUuid, roleNames, retrievedItems)
}
let retrievedItems = this.filterOutSyncConflictsForConsecutiveSyncs(
getItemsResult.items,
saveItemsResult.conflicts,
)
const isSharedVaultExclusiveSync = dto.sharedVaultUuids && dto.sharedVaultUuids.length > 0
if (this.isFirstSync(dto) && !isSharedVaultExclusiveSync) {
retrievedItems = await this.frontLoadKeysItemsToTop(dto.userUuid, roleNames, retrievedItems)
}
const sharedVaultsOrError = await this.getSharedVaultsUseCase.execute({
userUuid: dto.userUuid,
lastSyncTime: getItemsResult.lastSyncTime ?? undefined,
})
if (sharedVaultsOrError.isFailed()) {
return Result.fail(sharedVaultsOrError.getError())
}
const sharedVaults = sharedVaultsOrError.getValue()
const sharedVaultsOrError = await this.getSharedVaultsUseCase.execute({
userUuid: dto.userUuid,
lastSyncTime: getItemsResult.lastSyncTime ?? undefined,
})
if (sharedVaultsOrError.isFailed()) {
return Result.fail(sharedVaultsOrError.getError())
}
const sharedVaults = sharedVaultsOrError.getValue()
const sharedVaultInvitesOrError = await this.getSharedVaultInvitesSentToUserUseCase.execute({
userUuid: dto.userUuid,
lastSyncTime: getItemsResult.lastSyncTime ?? undefined,
})
if (sharedVaultInvitesOrError.isFailed()) {
return Result.fail(sharedVaultInvitesOrError.getError())
}
const sharedVaultInvites = sharedVaultInvitesOrError.getValue()
const sharedVaultInvitesOrError = await this.getSharedVaultInvitesSentToUserUseCase.execute({
userUuid: dto.userUuid,
lastSyncTime: getItemsResult.lastSyncTime ?? undefined,
})
if (sharedVaultInvitesOrError.isFailed()) {
return Result.fail(sharedVaultInvitesOrError.getError())
}
const sharedVaultInvites = sharedVaultInvitesOrError.getValue()
const messagesOrError = await this.getMessagesSentToUser.execute({
recipientUuid: dto.userUuid,
lastSyncTime: getItemsResult.lastSyncTime ?? undefined,
})
if (messagesOrError.isFailed()) {
return Result.fail(messagesOrError.getError())
}
const messages = messagesOrError.getValue()
const messagesOrError = await this.getMessagesSentToUser.execute({
recipientUuid: dto.userUuid,
lastSyncTime: getItemsResult.lastSyncTime ?? undefined,
})
if (messagesOrError.isFailed()) {
return Result.fail(messagesOrError.getError())
}
const messages = messagesOrError.getValue()
const notificationsOrError = await this.getUserNotifications.execute({
userUuid: dto.userUuid,
lastSyncTime: getItemsResult.lastSyncTime ?? undefined,
})
if (notificationsOrError.isFailed()) {
return Result.fail(notificationsOrError.getError())
}
const notifications = notificationsOrError.getValue()
const notificationsOrError = await this.getUserNotifications.execute({
userUuid: dto.userUuid,
lastSyncTime: getItemsResult.lastSyncTime ?? undefined,
})
if (notificationsOrError.isFailed()) {
return Result.fail(notificationsOrError.getError())
}
const notifications = notificationsOrError.getValue()
const syncResponse: SyncItemsResponse = {
retrievedItems,
syncToken: saveItemsResult.syncToken,
savedItems: saveItemsResult.savedItems,
conflicts: saveItemsResult.conflicts,
cursorToken: getItemsResult.cursorToken,
sharedVaultInvites,
sharedVaults,
messages,
notifications,
}
const syncResponse: SyncItemsResponse = {
retrievedItems,
syncToken: saveItemsResult.syncToken,
savedItems: saveItemsResult.savedItems,
conflicts: saveItemsResult.conflicts,
cursorToken: getItemsResult.cursorToken,
sharedVaultInvites,
sharedVaults,
messages,
notifications,
}
return Result.ok(syncResponse)
return Result.ok(syncResponse)
} catch (error) {
const itemHashUuids = dto.itemHashes.map((itemHash) => itemHash.props.uuid)
this.logger.error(
`Sync error for user ${dto.userUuid} syncing items ${itemHashUuids.join(',')}: ${(error as Error).message}`,
)
throw error
}
}
private isFirstSync(dto: SyncItemsDTO): boolean {

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,36 @@ 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
if (await this.hasAlreadyDataInSecondaryDatabase(userUuid)) {
return Result.fail(`Items for user ${userUuid.value} already exist in secondary database`)
const newItems = await this.getNewItemsCreatedInSecondaryDatabase(userUuid)
for (const existingItem of newItems.alreadyExistingInPrimary) {
this.logger.info(`Removing item ${existingItem.uuid.value} from secondary database`)
await (this.secondaryItemRepository as ItemRepositoryInterface).remove(existingItem)
}
if (newItems.newItemsInSecondary.length > 0) {
this.logger.info(
`Found ${newItems.newItemsInSecondary.length} new items in secondary database for user ${userUuid.value}`,
)
}
newItemsInSecondaryCount = newItems.newItemsInSecondary.length
}
await this.allowForSecondaryDatabaseToCatchUp()
const migrationTimeStart = this.timer.getTimestampInMicroseconds()
const migrationResult = await this.migrateItemsForUser(userUuid)
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) {
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 +67,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) {
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 +108,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,6 +121,52 @@ export class TransitionItemsFromPrimaryToSecondaryDatabaseForUser implements Use
await this.timer.sleep(twoSecondsInMilliseconds)
}
private async getNewItemsCreatedInSecondaryDatabase(userUuid: Uuid): Promise<{
alreadyExistingInPrimary: Item[]
newItemsInSecondary: Item[]
}> {
const items = await (this.secondaryItemRepository as ItemRepositoryInterface).findAll({
userUuid: userUuid.value,
})
const alreadyExistingInPrimary: Item[] = []
const newItemsInSecondary: Item[] = []
for (const item of items) {
const itemExistsInPrimary = await this.checkIfItemExistsInPrimaryDatabase(item)
if (itemExistsInPrimary) {
alreadyExistingInPrimary.push(item)
} else {
newItemsInSecondary.push(item)
}
}
return {
alreadyExistingInPrimary: alreadyExistingInPrimary,
newItemsInSecondary: newItemsInSecondary,
}
}
private async checkIfItemExistsInPrimaryDatabase(item: Item): Promise<boolean> {
const itemInPrimary = await this.primaryItemRepository.findByUuid(item.uuid)
if (itemInPrimary === null) {
return false
}
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 false
}
return true
}
private async migrateItemsForUser(userUuid: Uuid): Promise<Result<void>> {
try {
const totalItemsCountForUser = await this.primaryItemRepository.countAll({ userUuid: userUuid.value })
@@ -138,7 +201,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 +213,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

@@ -33,6 +33,12 @@ export class FSItemBackupService implements ItemBackupServiceInterface {
await promises.writeFile(path, contents)
const fileCreated = (await promises.stat(path)).isFile()
if (!fileCreated) {
throw new Error(`Could not create dump file ${path}`)
}
return path
}
}

View File

@@ -32,7 +32,7 @@ export class TypeORMSharedVaultInviteRepository implements SharedVaultInviteRepo
const persistence = await this.ormRepository
.createQueryBuilder('shared_vault_invite')
.where('shared_vault_invite.sender_uuid = :uuid', {
senderUuid: dto.senderUuid.value,
uuid: dto.senderUuid.value,
})
.andWhere('shared_vault_invite.shared_vault_uuid = :sharedVaultUuid', {
sharedVaultUuid: dto.sharedVaultUuid.value,

View File

@@ -3,6 +3,14 @@
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
## [1.10.39](https://github.com/standardnotes/server/compare/@standardnotes/websockets-server@1.10.38...@standardnotes/websockets-server@1.10.39) (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.39",
"version": "1.10.41",
"engines": {
"node": ">=18.0.0 <21.0.0"
},