Compare commits

..

12 Commits

Author SHA1 Message Date
standardci
d50c4440c2 chore(release): publish new version
- @standardnotes/home-server@1.15.79
 - @standardnotes/revisions-server@1.36.5
 - @standardnotes/syncing-server@1.103.1
2023-09-22 09:57:54 +00:00
Karol Sójko
921c30f641 fix: integrity check during transition 2023-09-22 11:27:54 +02:00
Karol Sójko
22540ee834 fix: processing migration optimization 2023-09-22 11:17:03 +02:00
Karol Sójko
4f4443a882 fix: disable cleaning secondary database on transition 2023-09-22 11:17:02 +02:00
standardci
80dbacf933 chore(release): publish new version
- @standardnotes/auth-server@1.145.0
 - @standardnotes/home-server@1.15.78
 - @standardnotes/syncing-server@1.103.0
2023-09-22 09:07:07 +00:00
Karol Sójko
dc77ff3e45 feat: remove user from all shared vaults upon account deletion (#843) 2023-09-22 10:49:53 +02:00
standardci
6515dcf487 chore(release): publish new version
- @standardnotes/home-server@1.15.77
 - @standardnotes/syncing-server@1.102.2
2023-09-22 07:54:08 +00:00
Karol Sójko
d0fd6b98df fix(syncing-server): error message 2023-09-22 09:35:37 +02:00
standardci
345efacb44 chore(release): publish new version
- @standardnotes/home-server@1.15.76
 - @standardnotes/revisions-server@1.36.4
2023-09-21 13:20:19 +00:00
Karol Sójko
d0dba1b66d fix(revisions): add log info about skipping already existing revision 2023-09-21 15:00:05 +02:00
standardci
da119af8b2 chore(release): publish new version
- @standardnotes/home-server@1.15.75
 - @standardnotes/syncing-server@1.102.1
2023-09-21 12:46:35 +00:00
Karol Sójko
a5da42bddd fix(syncing-server): add missing binding 2023-09-21 14:28:36 +02:00
25 changed files with 386 additions and 535 deletions

View File

@@ -3,7 +3,7 @@ module.exports = {
testEnvironment: 'node',
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.ts$',
testTimeout: 20000,
coverageReporters: ['text-summary'],
coverageReporters: ['text'],
reporters: ['summary'],
coverageThreshold: {
global: {

View File

@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.145.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.144.0...@standardnotes/auth-server@1.145.0) (2023-09-22)
### Features
* remove user from all shared vaults upon account deletion ([#843](https://github.com/standardnotes/server/issues/843)) ([dc77ff3](https://github.com/standardnotes/server/commit/dc77ff3e45983d231bc9c132802428e77b4be431))
# [1.144.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.143.9...@standardnotes/auth-server@1.144.0) (2023-09-21)
### Features

View File

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

View File

@@ -1008,7 +1008,16 @@ export class ContainerConfigLoader {
container.bind<UserRegisteredEventHandler>(TYPES.Auth_UserRegisteredEventHandler).to(UserRegisteredEventHandler)
container
.bind<AccountDeletionRequestedEventHandler>(TYPES.Auth_AccountDeletionRequestedEventHandler)
.to(AccountDeletionRequestedEventHandler)
.toConstantValue(
new AccountDeletionRequestedEventHandler(
container.get<UserRepositoryInterface>(TYPES.Auth_UserRepository),
container.get<SessionRepositoryInterface>(TYPES.Auth_SessionRepository),
container.get<EphemeralSessionRepositoryInterface>(TYPES.Auth_EphemeralSessionRepository),
container.get<RevokedSessionRepositoryInterface>(TYPES.Auth_RevokedSessionRepository),
container.get<RemoveSharedVaultUser>(TYPES.Auth_RemoveSharedVaultUser),
container.get<winston.Logger>(TYPES.Auth_Logger),
),
)
container
.bind<SubscriptionPurchasedEventHandler>(TYPES.Auth_SubscriptionPurchasedEventHandler)
.to(SubscriptionPurchasedEventHandler)

View File

@@ -1,114 +0,0 @@
import 'reflect-metadata'
import { AccountDeletionRequestedEvent } from '@standardnotes/domain-events'
import { Logger } from 'winston'
import { EphemeralSession } from '../Session/EphemeralSession'
import { EphemeralSessionRepositoryInterface } from '../Session/EphemeralSessionRepositoryInterface'
import { RevokedSession } from '../Session/RevokedSession'
import { RevokedSessionRepositoryInterface } from '../Session/RevokedSessionRepositoryInterface'
import { Session } from '../Session/Session'
import { SessionRepositoryInterface } from '../Session/SessionRepositoryInterface'
import { User } from '../User/User'
import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
import { AccountDeletionRequestedEventHandler } from './AccountDeletionRequestedEventHandler'
describe('AccountDeletionRequestedEventHandler', () => {
let userRepository: UserRepositoryInterface
let sessionRepository: SessionRepositoryInterface
let ephemeralSessionRepository: EphemeralSessionRepositoryInterface
let revokedSessionRepository: RevokedSessionRepositoryInterface
let logger: Logger
let session: Session
let ephemeralSession: EphemeralSession
let revokedSession: RevokedSession
let user: User
let event: AccountDeletionRequestedEvent
const createHandler = () =>
new AccountDeletionRequestedEventHandler(
userRepository,
sessionRepository,
ephemeralSessionRepository,
revokedSessionRepository,
logger,
)
beforeEach(() => {
user = {} as jest.Mocked<User>
userRepository = {} as jest.Mocked<UserRepositoryInterface>
userRepository.findOneByUuid = jest.fn().mockReturnValue(user)
userRepository.remove = jest.fn()
session = {
uuid: '1-2-3',
} as jest.Mocked<Session>
sessionRepository = {} as jest.Mocked<SessionRepositoryInterface>
sessionRepository.findAllByUserUuid = jest.fn().mockReturnValue([session])
sessionRepository.remove = jest.fn()
ephemeralSession = {
uuid: '2-3-4',
userUuid: '00000000-0000-0000-0000-000000000000',
} as jest.Mocked<EphemeralSession>
ephemeralSessionRepository = {} as jest.Mocked<EphemeralSessionRepositoryInterface>
ephemeralSessionRepository.findAllByUserUuid = jest.fn().mockReturnValue([ephemeralSession])
ephemeralSessionRepository.deleteOne = jest.fn()
revokedSession = {
uuid: '3-4-5',
} as jest.Mocked<RevokedSession>
revokedSessionRepository = {} as jest.Mocked<RevokedSessionRepositoryInterface>
revokedSessionRepository.findAllByUserUuid = jest.fn().mockReturnValue([revokedSession])
revokedSessionRepository.remove = jest.fn()
event = {} as jest.Mocked<AccountDeletionRequestedEvent>
event.createdAt = new Date(1)
event.payload = {
userUuid: '00000000-0000-0000-0000-000000000000',
userCreatedAtTimestamp: 1,
regularSubscriptionUuid: '2-3-4',
roleNames: ['CORE_USER'],
}
logger = {} as jest.Mocked<Logger>
logger.info = jest.fn()
logger.warn = jest.fn()
})
it('should remove a user', async () => {
await createHandler().handle(event)
expect(userRepository.remove).toHaveBeenCalledWith(user)
})
it('should not remove a user with invalid uuid', async () => {
event.payload.userUuid = 'invalid'
await createHandler().handle(event)
expect(userRepository.remove).not.toHaveBeenCalled()
})
it('should not remove a user if one does not exist', async () => {
userRepository.findOneByUuid = jest.fn().mockReturnValue(null)
await createHandler().handle(event)
expect(userRepository.remove).not.toHaveBeenCalled()
expect(sessionRepository.remove).not.toHaveBeenCalled()
expect(revokedSessionRepository.remove).not.toHaveBeenCalled()
expect(ephemeralSessionRepository.deleteOne).not.toHaveBeenCalled()
})
it('should remove all user sessions', async () => {
await createHandler().handle(event)
expect(sessionRepository.remove).toHaveBeenCalledWith(session)
expect(revokedSessionRepository.remove).toHaveBeenCalledWith(revokedSession)
expect(ephemeralSessionRepository.deleteOne).toHaveBeenCalledWith('2-3-4', '00000000-0000-0000-0000-000000000000')
})
})

View File

@@ -1,22 +1,21 @@
import { AccountDeletionRequestedEvent, DomainEventHandlerInterface } from '@standardnotes/domain-events'
import { inject, injectable } from 'inversify'
import { Uuid } from '@standardnotes/domain-core'
import { Logger } from 'winston'
import TYPES from '../../Bootstrap/Types'
import { EphemeralSessionRepositoryInterface } from '../Session/EphemeralSessionRepositoryInterface'
import { RevokedSessionRepositoryInterface } from '../Session/RevokedSessionRepositoryInterface'
import { SessionRepositoryInterface } from '../Session/SessionRepositoryInterface'
import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
import { Uuid } from '@standardnotes/domain-core'
import { RemoveSharedVaultUser } from '../UseCase/RemoveSharedVaultUser/RemoveSharedVaultUser'
@injectable()
export class AccountDeletionRequestedEventHandler implements DomainEventHandlerInterface {
constructor(
@inject(TYPES.Auth_UserRepository) private userRepository: UserRepositoryInterface,
@inject(TYPES.Auth_SessionRepository) private sessionRepository: SessionRepositoryInterface,
@inject(TYPES.Auth_EphemeralSessionRepository)
private userRepository: UserRepositoryInterface,
private sessionRepository: SessionRepositoryInterface,
private ephemeralSessionRepository: EphemeralSessionRepositoryInterface,
@inject(TYPES.Auth_RevokedSessionRepository) private revokedSessionRepository: RevokedSessionRepositoryInterface,
@inject(TYPES.Auth_Logger) private logger: Logger,
private revokedSessionRepository: RevokedSessionRepositoryInterface,
private removeSharedVaultUser: RemoveSharedVaultUser,
private logger: Logger,
) {}
async handle(event: AccountDeletionRequestedEvent): Promise<void> {
@@ -38,6 +37,13 @@ export class AccountDeletionRequestedEventHandler implements DomainEventHandlerI
await this.removeSessions(userUuid.value)
const result = await this.removeSharedVaultUser.execute({
userUuid: userUuid.value,
})
if (result.isFailed()) {
this.logger.error(`Could not remove shared vault user: ${result.getError()}`)
}
await this.userRepository.remove(user)
this.logger.info(`Finished account cleanup for user: ${userUuid.value}`)

View File

@@ -10,6 +10,12 @@ export class UserRemovedFromSharedVaultEventHandler implements DomainEventHandle
) {}
async handle(event: UserRemovedFromSharedVaultEvent): Promise<void> {
if (!event.payload.sharedVaultUuid) {
this.logger.error(`Shared vault uuid is missing from event: ${JSON.stringify(event)}`)
return
}
const result = await this.removeSharedVaultUser.execute({
userUuid: event.payload.userUuid,
sharedVaultUuid: event.payload.sharedVaultUuid,

View File

@@ -13,6 +13,7 @@ describe('RemoveSharedVaultUser', () => {
sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest
.fn()
.mockReturnValue({} as jest.Mocked<SharedVaultUser>)
sharedVaultUserRepository.findByUserUuid = jest.fn().mockReturnValue([{} as jest.Mocked<SharedVaultUser>])
sharedVaultUserRepository.remove = jest.fn()
})
@@ -28,6 +29,17 @@ describe('RemoveSharedVaultUser', () => {
expect(sharedVaultUserRepository.remove).toHaveBeenCalled()
})
it('should remove all shared vault users', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeFalsy()
expect(sharedVaultUserRepository.remove).toHaveBeenCalled()
})
it('should fail when user uuid is invalid', async () => {
const useCase = createUseCase()

View File

@@ -13,21 +13,31 @@ export class RemoveSharedVaultUser implements UseCaseInterface<void> {
}
const userUuid = userUuidOrError.getValue()
const sharedVaultUuidOrError = Uuid.create(dto.sharedVaultUuid)
if (sharedVaultUuidOrError.isFailed()) {
return Result.fail(sharedVaultUuidOrError.getError())
}
const sharedVaultUuid = sharedVaultUuidOrError.getValue()
const sharedVaultUser = await this.sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid({
userUuid,
sharedVaultUuid,
})
if (!sharedVaultUser) {
return Result.fail('Shared vault user not found')
let sharedVaultUuid: Uuid | undefined
if (dto.sharedVaultUuid !== undefined) {
const sharedVaultUuidOrError = Uuid.create(dto.sharedVaultUuid)
if (sharedVaultUuidOrError.isFailed()) {
return Result.fail(sharedVaultUuidOrError.getError())
}
sharedVaultUuid = sharedVaultUuidOrError.getValue()
}
await this.sharedVaultUserRepository.remove(sharedVaultUser)
if (sharedVaultUuid) {
const sharedVaultUser = await this.sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid({
userUuid,
sharedVaultUuid,
})
if (!sharedVaultUser) {
return Result.fail('Shared vault user not found')
}
await this.sharedVaultUserRepository.remove(sharedVaultUser)
} else {
const sharedVaultUsers = await this.sharedVaultUserRepository.findByUserUuid(userUuid)
for (const sharedVaultUser of sharedVaultUsers) {
await this.sharedVaultUserRepository.remove(sharedVaultUser)
}
}
return Result.ok()
}

View File

@@ -1,4 +1,4 @@
export interface RemoveSharedVaultUserDTO {
sharedVaultUuid: string
sharedVaultUuid?: string
userUuid: string
}

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.15.79](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.15.78...@standardnotes/home-server@1.15.79) (2023-09-22)
**Note:** Version bump only for package @standardnotes/home-server
## [1.15.78](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.15.77...@standardnotes/home-server@1.15.78) (2023-09-22)
**Note:** Version bump only for package @standardnotes/home-server
## [1.15.77](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.15.76...@standardnotes/home-server@1.15.77) (2023-09-22)
**Note:** Version bump only for package @standardnotes/home-server
## [1.15.76](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.15.75...@standardnotes/home-server@1.15.76) (2023-09-21)
**Note:** Version bump only for package @standardnotes/home-server
## [1.15.75](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.15.74...@standardnotes/home-server@1.15.75) (2023-09-21)
**Note:** Version bump only for package @standardnotes/home-server
## [1.15.74](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.15.73...@standardnotes/home-server@1.15.74) (2023-09-21)
**Note:** Version bump only for package @standardnotes/home-server

View File

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

View File

@@ -3,6 +3,20 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.36.5](https://github.com/standardnotes/server/compare/@standardnotes/revisions-server@1.36.4...@standardnotes/revisions-server@1.36.5) (2023-09-22)
### Bug Fixes
* disable cleaning secondary database on transition ([4f4443a](https://github.com/standardnotes/server/commit/4f4443a882f69c2e76ef831ef36347c9c54f31cd))
* integrity check during transition ([921c30f](https://github.com/standardnotes/server/commit/921c30f6415ef122a7d1af83ffa3f6840a42edba))
* processing migration optimization ([22540ee](https://github.com/standardnotes/server/commit/22540ee83436b986949127a6923285a702162706))
## [1.36.4](https://github.com/standardnotes/server/compare/@standardnotes/revisions-server@1.36.3...@standardnotes/revisions-server@1.36.4) (2023-09-21)
### Bug Fixes
* **revisions:** add log info about skipping already existing revision ([d0dba1b](https://github.com/standardnotes/server/commit/d0dba1b66df0fb4ab64ede8f0d4e1c4e2a23ad3c))
## [1.36.3](https://github.com/standardnotes/server/compare/@standardnotes/revisions-server@1.36.2...@standardnotes/revisions-server@1.36.3) (2023-09-21)
**Note:** Version bump only for package @standardnotes/revisions-server

View File

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

View File

@@ -5,7 +5,6 @@ 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(
@@ -29,76 +28,23 @@ export class TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser implements
}
const userUuid = userUuidOrError.getValue()
let newRevisionsInSecondaryCount = 0
let updatedRevisionsInSecondary: string[] = []
let alreadyIdenticalInSecondaryAndPrimary: string[] = []
if (await this.hasAlreadyDataInSecondaryDatabase(userUuid)) {
const { alreadyExistingInSecondaryAndPrimary, newRevisionsInSecondary, updatedInSecondary } =
await this.getNewRevisionsCreatedInSecondaryDatabase(userUuid)
this.logger.info(
`[${dto.userUuid}] ${alreadyExistingInSecondaryAndPrimary.length} already existing identical revisions in primary and secondary.`,
)
alreadyIdenticalInSecondaryAndPrimary = alreadyExistingInSecondaryAndPrimary
if (newRevisionsInSecondary.length > 0) {
this.logger.info(
`[${dto.userUuid}] Found ${newRevisionsInSecondary.length} new revisions in secondary database`,
)
}
newRevisionsInSecondaryCount = newRevisionsInSecondary.length
if (updatedInSecondary.length > 0) {
this.logger.info(`[${dto.userUuid}] Found ${updatedInSecondary.length} updated revisions in secondary database`)
}
updatedRevisionsInSecondary = updatedInSecondary
}
const updatedRevisionsInSecondaryCount = updatedRevisionsInSecondary.length
const migrationTimeStart = this.timer.getTimestampInMicroseconds()
this.logger.info(`[${dto.userUuid}] Migrating revisions`)
const migrationResult = await this.migrateRevisionsForUser(
userUuid,
updatedRevisionsInSecondary,
alreadyIdenticalInSecondaryAndPrimary,
)
const migrationResult = await this.migrateRevisionsForUser(userUuid)
if (migrationResult.isFailed()) {
if (newRevisionsInSecondaryCount === 0 && updatedRevisionsInSecondaryCount === 0) {
const cleanupResult = await this.deleteRevisionsForUser(userUuid, this.secondRevisionsRepository)
if (cleanupResult.isFailed()) {
this.logger.error(
`[${dto.userUuid}] Failed to clean up secondary database revisions: ${cleanupResult.getError()}`,
)
}
}
return Result.fail(migrationResult.getError())
}
const revisionsToSkipInIntegrityCheck = migrationResult.getValue()
await this.allowForSecondaryDatabaseToCatchUp()
const integrityCheckResult = await this.checkIntegrityBetweenPrimaryAndSecondaryDatabase(
userUuid,
newRevisionsInSecondaryCount,
updatedRevisionsInSecondary,
alreadyIdenticalInSecondaryAndPrimary,
revisionsToSkipInIntegrityCheck,
)
if (integrityCheckResult.isFailed()) {
if (newRevisionsInSecondaryCount === 0 && updatedRevisionsInSecondaryCount === 0) {
const cleanupResult = await this.deleteRevisionsForUser(userUuid, this.secondRevisionsRepository)
if (cleanupResult.isFailed()) {
this.logger.error(
`[${dto.userUuid}] Failed to clean up secondary database revisions: ${cleanupResult.getError()}`,
)
}
}
return Result.fail(integrityCheckResult.getError())
}
@@ -119,14 +65,11 @@ export class TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser implements
return Result.ok()
}
private async migrateRevisionsForUser(
userUuid: Uuid,
updatedRevisionsInSecondary: string[],
alreadyExistingInSecondaryAndPrimary: string[],
): Promise<Result<void>> {
private async migrateRevisionsForUser(userUuid: Uuid): Promise<Result<string[]>> {
try {
const totalRevisionsCountForUser = await this.primaryRevisionsRepository.countByUserUuid(userUuid)
const totalPages = Math.ceil(totalRevisionsCountForUser / this.pageSize)
const revisionsToSkipInIntegrityCheck = []
for (let currentPage = 1; currentPage <= totalPages; currentPage++) {
const query = {
userUuid: userUuid,
@@ -135,35 +78,47 @@ export class TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser implements
}
const revisions = await this.primaryRevisionsRepository.findByUserUuid(query)
for (const revision of revisions) {
try {
if (
updatedRevisionsInSecondary.find((updatedRevisionUuid) => updatedRevisionUuid === revision.id.toString())
) {
const revisionInSecondary = await (
this.secondRevisionsRepository as RevisionRepositoryInterface
).findOneByUuid(Uuid.create(revision.id.toString()).getValue(), revision.props.userUuid as Uuid, [])
if (revisionInSecondary !== null) {
if (revisionInSecondary.isIdenticalTo(revision)) {
this.logger.info(
`[${userUuid.value}] Revision ${revision.id.toString()} already exists in secondary database`,
)
continue
}
if (revisionInSecondary.props.dates.updatedAt > revision.props.dates.updatedAt) {
this.logger.info(
`[${userUuid.value}] Revision ${revision.id.toString()} is older than revision in secondary database`,
)
revisionsToSkipInIntegrityCheck.push(revision.id.toString())
continue
}
this.logger.info(
`[${
userUuid.value
}] Skipping saving revision ${revision.id.toString()} as it was updated in secondary database`,
}] Removing revision ${revision.id.toString()} in secondary database as it is not identical to revision in primary database`,
)
continue
}
if (
alreadyExistingInSecondaryAndPrimary.find(
(alreadyExistingRevisionUuid) => alreadyExistingRevisionUuid === revision.id.toString(),
await (this.secondRevisionsRepository as RevisionRepositoryInterface).removeOneByUuid(
Uuid.create(revisionInSecondary.id.toString()).getValue(),
revisionInSecondary.props.userUuid as Uuid,
)
) {
continue
await this.allowForSecondaryDatabaseToCatchUp()
}
const didSave = await (this.secondRevisionsRepository as RevisionRepositoryInterface).insert(revision)
if (!didSave) {
return Result.fail(`Failed to save revision ${revision.id.toString()} to secondary database`)
this.logger.error(`Failed to save revision ${revision.id.toString()} to secondary database`)
}
} catch (error) {
return Result.fail(
this.logger.error(
`Errored when saving revision ${revision.id.toString()} to secondary database: ${
(error as Error).message
}`,
@@ -172,7 +127,7 @@ export class TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser implements
}
}
return Result.ok()
return Result.ok(revisionsToSkipInIntegrityCheck)
} catch (error) {
return Result.fail(`Errored when migrating revisions for user ${userUuid.value}: ${(error as Error).message}`)
}
@@ -183,6 +138,8 @@ export class TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser implements
revisionRepository: RevisionRepositoryInterface,
): Promise<Result<void>> {
try {
this.logger.info(`[${userUuid.value}] Deleting all revisions from primary database`)
await revisionRepository.removeByUserUuid(userUuid)
return Result.ok()
@@ -196,115 +153,21 @@ export class TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser implements
await this.timer.sleep(twoSecondsInMilliseconds)
}
private async hasAlreadyDataInSecondaryDatabase(userUuid: Uuid): Promise<boolean> {
const totalRevisionsCountForUserInSecondary = await (
this.secondRevisionsRepository as RevisionRepositoryInterface
).countByUserUuid(userUuid)
const hasAlreadyDataInSecondaryDatabase = totalRevisionsCountForUserInSecondary > 0
if (hasAlreadyDataInSecondaryDatabase) {
this.logger.info(
`[${userUuid.value}] User has already ${totalRevisionsCountForUserInSecondary} revisions in secondary database`,
)
}
return hasAlreadyDataInSecondaryDatabase
}
private async getNewRevisionsCreatedInSecondaryDatabase(userUuid: Uuid): Promise<{
alreadyExistingInSecondaryAndPrimary: string[]
newRevisionsInSecondary: string[]
updatedInSecondary: string[]
}> {
this.logger.info(`[${userUuid.value}] Checking for new revisions created in secondary database`)
const totalRevisionsCountForUser = await (
this.secondRevisionsRepository as RevisionRepositoryInterface
).countByUserUuid(userUuid)
const totalPages = Math.ceil(totalRevisionsCountForUser / this.pageSize)
const alreadyExistingInSecondaryAndPrimary: string[] = []
const newRevisionsInSecondary: string[] = []
const updatedInSecondary: string[] = []
for (let currentPage = 1; currentPage <= totalPages; currentPage++) {
const query = {
userUuid: userUuid,
offset: (currentPage - 1) * this.pageSize,
limit: this.pageSize,
}
const revisions = await (this.secondRevisionsRepository as RevisionRepositoryInterface).findByUserUuid(query)
for (const revision of revisions) {
const { identicalRevisionInPrimary, newerRevisionInSecondary } =
await this.checkIfRevisionExistsInPrimaryDatabase(revision)
if (identicalRevisionInPrimary !== null) {
alreadyExistingInSecondaryAndPrimary.push(revision.id.toString())
continue
}
if (newerRevisionInSecondary !== null) {
updatedInSecondary.push(newerRevisionInSecondary.id.toString())
continue
}
if (identicalRevisionInPrimary === null && newerRevisionInSecondary === null) {
newRevisionsInSecondary.push(revision.id.toString())
continue
}
}
}
return {
alreadyExistingInSecondaryAndPrimary,
newRevisionsInSecondary,
updatedInSecondary,
}
}
private async checkIfRevisionExistsInPrimaryDatabase(
revision: Revision,
): Promise<{ identicalRevisionInPrimary: Revision | null; newerRevisionInSecondary: Revision | null }> {
const revisionInPrimary = await this.primaryRevisionsRepository.findOneByUuid(
Uuid.create(revision.id.toString()).getValue(),
revision.props.userUuid as Uuid,
[],
)
if (revisionInPrimary === null) {
return {
identicalRevisionInPrimary: null,
newerRevisionInSecondary: null,
}
}
if (!revision.isIdenticalTo(revisionInPrimary)) {
this.logger.error(
`[${revision.props.userUuid
?.value}] 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 {
identicalRevisionInPrimary: null,
newerRevisionInSecondary:
revision.props.dates.updatedAt > revisionInPrimary.props.dates.updatedAt ? revision : null,
}
}
return {
identicalRevisionInPrimary: revisionInPrimary,
newerRevisionInSecondary: null,
}
}
private async checkIntegrityBetweenPrimaryAndSecondaryDatabase(
userUuid: Uuid,
newRevisionsInSecondaryCount: number,
updatedRevisionsInSecondary: string[],
alreadyExistingInSecondaryAndPrimary: string[],
revisionsToSkipInIntegrityCheck: string[],
): Promise<Result<boolean>> {
try {
const totalRevisionsCountForUserInPrimary = await this.primaryRevisionsRepository.countByUserUuid(userUuid)
const totalRevisionsCountForUserInSecondary = await (
this.secondRevisionsRepository as RevisionRepositoryInterface
).countByUserUuid(userUuid)
if (totalRevisionsCountForUserInPrimary > 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})`,
)
}
const totalPages = Math.ceil(totalRevisionsCountForUserInPrimary / this.pageSize)
for (let currentPage = 1; currentPage <= totalPages; currentPage++) {
@@ -331,22 +194,7 @@ export class TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser implements
return Result.fail(`Revision ${revision.id.toString()} not found in secondary database`)
}
if (
updatedRevisionsInSecondary.find((updatedRevisionUuid) => updatedRevisionUuid === revision.id.toString())
) {
this.logger.info(
`[${
userUuid.value
}] Skipping integrity check for revision ${revision.id.toString()} as it was updated in secondary database`,
)
continue
}
if (
alreadyExistingInSecondaryAndPrimary.find(
(alreadyExistingRevisionUuid) => alreadyExistingRevisionUuid === revision.id.toString(),
)
) {
if (revisionsToSkipInIntegrityCheck.includes(revision.id.toString())) {
continue
}
@@ -360,19 +208,6 @@ export class TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser implements
}
}
const totalRevisionsCountForUserInSecondary = await (
this.secondRevisionsRepository as RevisionRepositoryInterface
).countByUserUuid(userUuid)
if (
totalRevisionsCountForUserInPrimary + newRevisionsInSecondaryCount !==
totalRevisionsCountForUserInSecondary
) {
return Result.fail(
`Total revisions count for user ${userUuid.value} in primary database (${totalRevisionsCountForUserInPrimary} + ${newRevisionsInSecondaryCount}) does not match total revisions count in secondary database (${totalRevisionsCountForUserInSecondary})`,
)
}
return Result.ok()
} catch (error) {
return Result.fail(

View File

@@ -3,6 +3,32 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.103.1](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.103.0...@standardnotes/syncing-server@1.103.1) (2023-09-22)
### Bug Fixes
* disable cleaning secondary database on transition ([4f4443a](https://github.com/standardnotes/syncing-server-js/commit/4f4443a882f69c2e76ef831ef36347c9c54f31cd))
* integrity check during transition ([921c30f](https://github.com/standardnotes/syncing-server-js/commit/921c30f6415ef122a7d1af83ffa3f6840a42edba))
* processing migration optimization ([22540ee](https://github.com/standardnotes/syncing-server-js/commit/22540ee83436b986949127a6923285a702162706))
# [1.103.0](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.102.2...@standardnotes/syncing-server@1.103.0) (2023-09-22)
### Features
* remove user from all shared vaults upon account deletion ([#843](https://github.com/standardnotes/syncing-server-js/issues/843)) ([dc77ff3](https://github.com/standardnotes/syncing-server-js/commit/dc77ff3e45983d231bc9c132802428e77b4be431))
## [1.102.2](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.102.1...@standardnotes/syncing-server@1.102.2) (2023-09-22)
### Bug Fixes
* **syncing-server:** error message ([d0fd6b9](https://github.com/standardnotes/syncing-server-js/commit/d0fd6b98df58f6bd2050ff415515c692ecd32bef))
## [1.102.1](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.102.0...@standardnotes/syncing-server@1.102.1) (2023-09-21)
### Bug Fixes
* **syncing-server:** add missing binding ([a5da42b](https://github.com/standardnotes/syncing-server-js/commit/a5da42bdddc9bad0d641ad9a50932133b76a546a))
# [1.102.0](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.101.1...@standardnotes/syncing-server@1.102.0) (2023-09-21)
### Features

View File

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

View File

@@ -169,6 +169,7 @@ import { DeleteSharedVaults } from '../Domain/UseCase/SharedVaults/DeleteSharedV
import { RemoveItemsFromSharedVault } from '../Domain/UseCase/SharedVaults/RemoveItemsFromSharedVault/RemoveItemsFromSharedVault'
import { SharedVaultRemovedEventHandler } from '../Domain/Handler/SharedVaultRemovedEventHandler'
import { DesignateSurvivor } from '../Domain/UseCase/SharedVaults/DesignateSurvivor/DesignateSurvivor'
import { RemoveUserFromSharedVaults } from '../Domain/UseCase/SharedVaults/RemoveUserFromSharedVaults/RemoveUserFromSharedVaults'
export class ContainerConfigLoader {
private readonly DEFAULT_CONTENT_SIZE_TRANSFER_LIMIT = 10_000_000
@@ -876,6 +877,15 @@ export class ContainerConfigLoader {
container.get<DomainEventPublisherInterface>(TYPES.Sync_DomainEventPublisher),
),
)
container
.bind<RemoveUserFromSharedVaults>(TYPES.Sync_RemoveUserFromSharedVaults)
.toConstantValue(
new RemoveUserFromSharedVaults(
container.get<SharedVaultUserRepositoryInterface>(TYPES.Sync_SharedVaultUserRepository),
container.get<RemoveUserFromSharedVault>(TYPES.Sync_RemoveSharedVaultUser),
container.get<Logger>(TYPES.Sync_Logger),
),
)
// Services
container
@@ -938,6 +948,7 @@ export class ContainerConfigLoader {
new AccountDeletionRequestedEventHandler(
container.get<ItemRepositoryResolverInterface>(TYPES.Sync_ItemRepositoryResolver),
container.get<DeleteSharedVaults>(TYPES.Sync_DeleteSharedVaults),
container.get<RemoveUserFromSharedVaults>(TYPES.Sync_RemoveUserFromSharedVaults),
container.get<Logger>(TYPES.Sync_Logger),
),
)
@@ -1143,10 +1154,13 @@ export class ContainerConfigLoader {
.bind<BaseSharedVaultUsersController>(TYPES.Sync_BaseSharedVaultUsersController)
.toConstantValue(
new BaseSharedVaultUsersController(
container.get(TYPES.Sync_GetSharedVaultUsers),
container.get(TYPES.Sync_RemoveSharedVaultUser),
container.get(TYPES.Sync_SharedVaultUserHttpMapper),
container.get(TYPES.Sync_ControllerContainer),
container.get<GetSharedVaultUsers>(TYPES.Sync_GetSharedVaultUsers),
container.get<RemoveUserFromSharedVault>(TYPES.Sync_RemoveSharedVaultUser),
container.get<DesignateSurvivor>(TYPES.Sync_DesignateSurvivor),
container.get<MapperInterface<SharedVaultUser, SharedVaultUserHttpRepresentation>>(
TYPES.Sync_SharedVaultUserHttpMapper,
),
container.get<ControllerContainerInterface>(TYPES.Sync_ControllerContainer),
),
)
container

View File

@@ -88,6 +88,7 @@ const TYPES = {
Sync_SendEventToClient: Symbol.for('Sync_SendEventToClient'),
Sync_RemoveItemsFromSharedVault: Symbol.for('Sync_RemoveItemsFromSharedVault'),
Sync_DesignateSurvivor: Symbol.for('Sync_DesignateSurvivor'),
Sync_RemoveUserFromSharedVaults: Symbol.for('Sync_RemoveUserFromSharedVaults'),
// Handlers
Sync_AccountDeletionRequestedEventHandler: Symbol.for('Sync_AccountDeletionRequestedEventHandler'),
Sync_DuplicateItemSyncedEventHandler: Symbol.for('Sync_DuplicateItemSyncedEventHandler'),

View File

@@ -4,11 +4,13 @@ import { Logger } from 'winston'
import { ItemRepositoryResolverInterface } from '../Item/ItemRepositoryResolverInterface'
import { DeleteSharedVaults } from '../UseCase/SharedVaults/DeleteSharedVaults/DeleteSharedVaults'
import { RemoveUserFromSharedVaults } from '../UseCase/SharedVaults/RemoveUserFromSharedVaults/RemoveUserFromSharedVaults'
export class AccountDeletionRequestedEventHandler implements DomainEventHandlerInterface {
constructor(
private itemRepositoryResolver: ItemRepositoryResolverInterface,
private deleteSharedVaults: DeleteSharedVaults,
private removeUserFromSharedVaults: RemoveUserFromSharedVaults,
private logger: Logger,
) {}
@@ -23,13 +25,24 @@ export class AccountDeletionRequestedEventHandler implements DomainEventHandlerI
await itemRepository.deleteByUserUuid(event.payload.userUuid)
const result = await this.deleteSharedVaults.execute({
const deletingVaultsResult = await this.deleteSharedVaults.execute({
ownerUuid: event.payload.userUuid,
})
if (result.isFailed()) {
this.logger.error(`Failed to delete shared vaults for user: ${event.payload.userUuid}: ${result.getError()}`)
if (deletingVaultsResult.isFailed()) {
this.logger.error(
`Failed to delete shared vaults for user: ${event.payload.userUuid}: ${deletingVaultsResult.getError()}`,
)
}
return
const deletingUserFromOtherVaultsResult = await this.removeUserFromSharedVaults.execute({
userUuid: event.payload.userUuid,
})
if (deletingUserFromOtherVaultsResult.isFailed()) {
this.logger.error(
`Failed to remove user: ${
event.payload.userUuid
} from shared vaults: ${deletingUserFromOtherVaultsResult.getError()}`,
)
}
this.logger.info(`Finished account cleanup for user: ${event.payload.userUuid}`)

View File

@@ -0,0 +1,79 @@
import { Result, SharedVaultUser, SharedVaultUserPermission, Timestamps, Uuid } from '@standardnotes/domain-core'
import { Logger } from 'winston'
import { SharedVaultUserRepositoryInterface } from '../../../SharedVault/User/SharedVaultUserRepositoryInterface'
import { RemoveUserFromSharedVault } from '../RemoveUserFromSharedVault/RemoveUserFromSharedVault'
import { RemoveUserFromSharedVaults } from './RemoveUserFromSharedVaults'
describe('RemoveUserFromSharedVaults', () => {
let sharedVaultUserRepository: SharedVaultUserRepositoryInterface
let sharedVaultUser: SharedVaultUser
let removeUserFromSharedVault: RemoveUserFromSharedVault
let logger: Logger
const createUseCase = () =>
new RemoveUserFromSharedVaults(sharedVaultUserRepository, removeUserFromSharedVault, logger)
beforeEach(() => {
sharedVaultUser = SharedVaultUser.create({
permission: SharedVaultUserPermission.create(SharedVaultUserPermission.PERMISSIONS.Write).getValue(),
sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
isDesignatedSurvivor: false,
}).getValue()
sharedVaultUserRepository = {} as jest.Mocked<SharedVaultUserRepositoryInterface>
sharedVaultUserRepository.findByUserUuid = jest.fn().mockResolvedValue([sharedVaultUser])
removeUserFromSharedVault = {} as jest.Mocked<RemoveUserFromSharedVault>
removeUserFromSharedVault.execute = jest.fn().mockResolvedValue(Result.ok())
logger = {} as jest.Mocked<Logger>
logger.error = jest.fn()
})
it('should remove user from shared vaults', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeFalsy()
expect(removeUserFromSharedVault.execute).toHaveBeenCalledTimes(1)
expect(removeUserFromSharedVault.execute).toHaveBeenCalledWith({
sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
originatorUuid: '00000000-0000-0000-0000-000000000000',
userUuid: '00000000-0000-0000-0000-000000000000',
forceRemoveOwner: true,
})
})
it('should log error if removing user from shared vault fails', async () => {
removeUserFromSharedVault.execute = jest.fn().mockResolvedValue(Result.fail('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 remove user: 00000000-0000-0000-0000-000000000000 from shared vault: 00000000-0000-0000-0000-000000000000: error',
)
})
it('should fail if the user uuid is invalid', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: 'invalid',
})
expect(result.isFailed()).toBeTruthy()
expect(removeUserFromSharedVault.execute).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,41 @@
import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
import { SharedVaultUserRepositoryInterface } from '../../../SharedVault/User/SharedVaultUserRepositoryInterface'
import { RemoveUserFromSharedVault } from '../RemoveUserFromSharedVault/RemoveUserFromSharedVault'
import { Logger } from 'winston'
import { RemoveUserFromSharedVaultsDTO } from './RemoveUserFromSharedVaultsDTO'
export class RemoveUserFromSharedVaults implements UseCaseInterface<void> {
constructor(
private sharedVaultUserRepository: SharedVaultUserRepositoryInterface,
private removeUserFromSharedVault: RemoveUserFromSharedVault,
private logger: Logger,
) {}
async execute(dto: RemoveUserFromSharedVaultsDTO): Promise<Result<void>> {
const userUuidOrError = Uuid.create(dto.userUuid)
if (userUuidOrError.isFailed()) {
return Result.fail(userUuidOrError.getError())
}
const userUuid = userUuidOrError.getValue()
const sharedVaultUsers = await this.sharedVaultUserRepository.findByUserUuid(userUuid)
for (const sharedVaultUser of sharedVaultUsers) {
const result = await this.removeUserFromSharedVault.execute({
sharedVaultUuid: sharedVaultUser.props.sharedVaultUuid.value,
originatorUuid: userUuid.value,
userUuid: userUuid.value,
forceRemoveOwner: true,
})
if (result.isFailed()) {
this.logger.error(
`Failed to remove user: ${userUuid.value} from shared vault: ${
sharedVaultUser.props.sharedVaultUuid.value
}: ${result.getError()}`,
)
}
}
return Result.ok()
}
}

View File

@@ -0,0 +1,3 @@
export interface RemoveUserFromSharedVaultsDTO {
userUuid: string
}

View File

@@ -1,12 +1,11 @@
/* istanbul ignore file */
import { TimerInterface } from '@standardnotes/time'
import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
import { Logger } from 'winston'
import { TransitionItemsFromPrimaryToSecondaryDatabaseForUserDTO } from './TransitionItemsFromPrimaryToSecondaryDatabaseForUserDTO'
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(
@@ -30,73 +29,23 @@ export class TransitionItemsFromPrimaryToSecondaryDatabaseForUser implements Use
}
const userUuid = userUuidOrError.getValue()
let newItemsInSecondaryCount = 0
let updatedItemsInSecondary: string[] = []
let alreadyIdenticalInSecondaryAndPrimary: string[] = []
if (await this.hasAlreadyDataInSecondaryDatabase(userUuid)) {
const { alreadyExistingInSecondaryAndPrimary, newItemsInSecondary, updatedInSecondary } =
await this.getNewItemsCreatedInSecondaryDatabase(userUuid)
this.logger.info(
`[${dto.userUuid}] ${alreadyExistingInSecondaryAndPrimary.length} already existing identical items in primary and secondary.`,
)
alreadyIdenticalInSecondaryAndPrimary = alreadyExistingInSecondaryAndPrimary
if (newItemsInSecondary.length > 0) {
this.logger.info(`[${dto.userUuid}] Found ${newItemsInSecondary.length} new items in secondary database.`)
}
newItemsInSecondaryCount = newItemsInSecondary.length
if (updatedInSecondary.length > 0) {
this.logger.info(`[${dto.userUuid}] Found ${updatedInSecondary.length} updated items in secondary database.`)
}
updatedItemsInSecondary = updatedInSecondary
}
const updatedItemsInSecondaryCount = updatedItemsInSecondary.length
const migrationTimeStart = this.timer.getTimestampInMicroseconds()
this.logger.info(`[${dto.userUuid}] Migrating items`)
const migrationResult = await this.migrateItemsForUser(
userUuid,
updatedItemsInSecondary,
alreadyIdenticalInSecondaryAndPrimary,
)
const migrationResult = await this.migrateItemsForUser(userUuid)
if (migrationResult.isFailed()) {
if (newItemsInSecondaryCount === 0 && updatedItemsInSecondaryCount === 0) {
const cleanupResult = await this.deleteItemsForUser(userUuid, this.secondaryItemRepository)
if (cleanupResult.isFailed()) {
this.logger.error(
`[${dto.userUuid}] Failed to clean up secondary database items: ${cleanupResult.getError()}`,
)
}
}
return Result.fail(migrationResult.getError())
}
const itemsToSkipInIntegrityCheck = migrationResult.getValue()
await this.allowForSecondaryDatabaseToCatchUp()
const integrityCheckResult = await this.checkIntegrityBetweenPrimaryAndSecondaryDatabase(
userUuid,
newItemsInSecondaryCount,
updatedItemsInSecondary,
alreadyIdenticalInSecondaryAndPrimary,
itemsToSkipInIntegrityCheck,
)
if (integrityCheckResult.isFailed()) {
if (newItemsInSecondaryCount === 0 && updatedItemsInSecondaryCount === 0) {
const cleanupResult = await this.deleteItemsForUser(userUuid, this.secondaryItemRepository)
if (cleanupResult.isFailed()) {
this.logger.error(
`[${dto.userUuid}] Failed to clean up secondary database items: ${cleanupResult.getError()}`,
)
}
}
return Result.fail(integrityCheckResult.getError())
}
@@ -117,108 +66,16 @@ export class TransitionItemsFromPrimaryToSecondaryDatabaseForUser implements Use
return Result.ok()
}
private async hasAlreadyDataInSecondaryDatabase(userUuid: Uuid): Promise<boolean> {
const totalItemsCountForUser = await (this.secondaryItemRepository as ItemRepositoryInterface).countAll({
userUuid: userUuid.value,
})
const hasAlreadyDataInSecondaryDatabase = totalItemsCountForUser > 0
if (hasAlreadyDataInSecondaryDatabase) {
this.logger.info(`[${userUuid.value}] User has already ${totalItemsCountForUser} items in secondary database`)
}
return hasAlreadyDataInSecondaryDatabase
}
private async allowForSecondaryDatabaseToCatchUp(): Promise<void> {
const twoSecondsInMilliseconds = 2_000
await this.timer.sleep(twoSecondsInMilliseconds)
}
private async getNewItemsCreatedInSecondaryDatabase(userUuid: Uuid): Promise<{
alreadyExistingInSecondaryAndPrimary: string[]
newItemsInSecondary: string[]
updatedInSecondary: string[]
}> {
this.logger.info(`[${userUuid.value}] Checking for new items in secondary database`)
const alreadyExistingInSecondaryAndPrimary: string[] = []
const updatedInSecondary: string[] = []
const newItemsInSecondary: string[] = []
const totalItemsCountForUser = await (this.secondaryItemRepository as ItemRepositoryInterface).countAll({
userUuid: userUuid.value,
})
const totalPages = Math.ceil(totalItemsCountForUser / this.pageSize)
for (let currentPage = 1; currentPage <= totalPages; currentPage++) {
const query: ItemQuery = {
userUuid: userUuid.value,
offset: (currentPage - 1) * this.pageSize,
limit: this.pageSize,
sortOrder: 'ASC',
sortBy: 'uuid',
}
const items = await (this.secondaryItemRepository as ItemRepositoryInterface).findAll(query)
for (const item of items) {
const { identicalItemInPrimary, newerItemInSecondary } = await this.checkIfItemExistsInPrimaryDatabase(item)
if (identicalItemInPrimary !== null) {
alreadyExistingInSecondaryAndPrimary.push(item.id.toString())
continue
}
if (newerItemInSecondary !== null) {
updatedInSecondary.push(newerItemInSecondary.id.toString())
continue
}
if (identicalItemInPrimary === null && newerItemInSecondary === null) {
newItemsInSecondary.push(item.id.toString())
continue
}
}
}
return {
alreadyExistingInSecondaryAndPrimary,
newItemsInSecondary,
updatedInSecondary,
}
}
private async checkIfItemExistsInPrimaryDatabase(
item: Item,
): Promise<{ identicalItemInPrimary: Item | null; newerItemInSecondary: Item | null }> {
const itemInPrimary = await this.primaryItemRepository.findByUuid(item.uuid)
if (itemInPrimary === null) {
return { identicalItemInPrimary: null, newerItemInSecondary: null }
}
if (!item.isIdenticalTo(itemInPrimary)) {
this.logger.error(
`[${
item.props.userUuid.value
}] Item ${item.id.toString()} is not identical in primary and secondary database. Item in secondary database: ${JSON.stringify(
item,
)}, item in primary database: ${JSON.stringify(itemInPrimary)}`,
)
return {
identicalItemInPrimary: null,
newerItemInSecondary: item.props.timestamps.updatedAt > itemInPrimary.props.timestamps.updatedAt ? item : null,
}
}
return { identicalItemInPrimary: itemInPrimary, newerItemInSecondary: null }
}
private async migrateItemsForUser(
userUuid: Uuid,
updatedItemsInSecondary: string[],
alreadyExistingInSecondaryAndPrimary: string[],
): Promise<Result<void>> {
private async migrateItemsForUser(userUuid: Uuid): Promise<Result<string[]>> {
try {
const totalItemsCountForUser = await this.primaryItemRepository.countAll({ userUuid: userUuid.value })
const totalPages = Math.ceil(totalItemsCountForUser / this.pageSize)
const itemsToSkipInIntegrityCheck = []
for (let currentPage = 1; currentPage <= totalPages; currentPage++) {
const query: ItemQuery = {
userUuid: userUuid.value,
@@ -231,22 +88,42 @@ export class TransitionItemsFromPrimaryToSecondaryDatabaseForUser implements Use
const items = await this.primaryItemRepository.findAll(query)
for (const item of items) {
if (updatedItemsInSecondary.find((updatedItemUuid) => item.uuid.value === updatedItemUuid)) {
this.logger.info(
`[${userUuid.value}] Skipping saving item ${item.uuid.value} as it was updated in secondary database`,
try {
const itemInSecondary = await (this.secondaryItemRepository as ItemRepositoryInterface).findByUuid(
item.uuid,
)
continue
}
if (alreadyExistingInSecondaryAndPrimary.find((itemUuid) => item.uuid.value === itemUuid)) {
continue
}
if (itemInSecondary !== null) {
if (itemInSecondary.isIdenticalTo(item)) {
this.logger.info(`[${userUuid.value}] Item ${item.uuid.value} already exists in secondary database`)
continue
}
if (itemInSecondary.props.timestamps.updatedAt > item.props.timestamps.updatedAt) {
this.logger.info(`[${userUuid.value}] Item ${item.uuid.value} is older than item in secondary database`)
itemsToSkipInIntegrityCheck.push(item.uuid.value)
await (this.secondaryItemRepository as ItemRepositoryInterface).save(item)
continue
}
this.logger.info(
`[${userUuid.value}] Removing item ${item.uuid.value} in secondary database as it is not identical to item in primary database`,
)
await (this.secondaryItemRepository as ItemRepositoryInterface).removeByUuid(item.uuid)
await this.allowForSecondaryDatabaseToCatchUp()
}
await (this.secondaryItemRepository as ItemRepositoryInterface).save(item)
} catch (error) {
this.logger.error(
`Errored when saving item ${item.uuid.value} to secondary database: ${(error as Error).message}`,
)
}
}
}
return Result.ok()
return Result.ok(itemsToSkipInIntegrityCheck)
} catch (error) {
return Result.fail((error as Error).message)
}
@@ -254,6 +131,8 @@ export class TransitionItemsFromPrimaryToSecondaryDatabaseForUser implements Use
private async deleteItemsForUser(userUuid: Uuid, itemRepository: ItemRepositoryInterface): Promise<Result<void>> {
try {
this.logger.info(`[${userUuid.value}] Cleaning up primary database items`)
await itemRepository.deleteByUserUuid(userUuid.value)
return Result.ok()
@@ -264,9 +143,7 @@ export class TransitionItemsFromPrimaryToSecondaryDatabaseForUser implements Use
private async checkIntegrityBetweenPrimaryAndSecondaryDatabase(
userUuid: Uuid,
newItemsInSecondaryCount: number,
updatedItemsInSecondary: string[],
alreadyExistingInSecondaryAndPrimary: string[],
itemsToSkipInIntegrityCheck: string[],
): Promise<Result<boolean>> {
try {
const totalItemsCountForUserInPrimary = await this.primaryItemRepository.countAll({ userUuid: userUuid.value })
@@ -276,9 +153,9 @@ export class TransitionItemsFromPrimaryToSecondaryDatabaseForUser implements Use
userUuid: userUuid.value,
})
if (totalItemsCountForUserInPrimary + newItemsInSecondaryCount !== totalItemsCountForUserInSecondary) {
if (totalItemsCountForUserInPrimary > totalItemsCountForUserInSecondary) {
return Result.fail(
`Total items count for user ${userUuid.value} in primary database (${totalItemsCountForUserInPrimary} + ${newItemsInSecondaryCount}) does not match total items count in secondary database (${totalItemsCountForUserInSecondary})`,
`Total items count for user ${userUuid.value} in primary database (${totalItemsCountForUserInPrimary}) does not match total items count in secondary database (${totalItemsCountForUserInSecondary})`,
)
}
@@ -300,14 +177,7 @@ export class TransitionItemsFromPrimaryToSecondaryDatabaseForUser implements Use
return Result.fail(`Item ${item.uuid.value} not found in secondary database`)
}
if (updatedItemsInSecondary.find((updatedItemUuid) => item.uuid.value === updatedItemUuid)) {
this.logger.info(
`[${userUuid.value}] Skipping integrity check for item ${item.uuid.value} as it was updated in secondary database`,
)
continue
}
if (alreadyExistingInSecondaryAndPrimary.find((itemUuid) => item.uuid.value === itemUuid)) {
if (itemsToSkipInIntegrityCheck.includes(item.id.toString())) {
continue
}

View File

@@ -20,7 +20,7 @@ export class MessagePersistenceMapper implements MapperInterface<Message, TypeOR
const timestampsOrError = Timestamps.create(projection.createdAtTimestamp, projection.updatedAtTimestamp)
if (timestampsOrError.isFailed()) {
throw new Error(`Failed to create notification from projection: ${timestampsOrError.getError()}`)
throw new Error(`Failed to create message from projection: ${timestampsOrError.getError()}`)
}
const timestamps = timestampsOrError.getValue()