Compare commits

..

4 Commits

Author SHA1 Message Date
standardci
3d6559921b chore(release): publish new version
- @standardnotes/home-server@1.14.1
 - @standardnotes/scheduler-server@1.20.24
 - @standardnotes/syncing-server@1.81.0
2023-08-21 09:00:52 +00:00
Karol Sójko
15a7f0e71a fix(syncing-server): DocumentDB retry writes support (#703)
* fix(syncing-server): DocumentDB retry writes support

* fix: auth source for mongo
2023-08-21 10:25:56 +02:00
Karol Sójko
3e56243d6f fix(scheduler): remove exit interview form link (#702) 2023-08-21 08:42:21 +02:00
Karol Sójko
032fcb938d feat(syncing-server): add use case for migrating items from one database to another (#701) 2023-08-18 17:25:24 +02:00
15 changed files with 666 additions and 5 deletions

View File

@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.14.1](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.14.0...@standardnotes/home-server@1.14.1) (2023-08-21)
**Note:** Version bump only for package @standardnotes/home-server
# [1.14.0](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.13.51...@standardnotes/home-server@1.14.0) (2023-08-18)
### Features

View File

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

View File

@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.20.24](https://github.com/standardnotes/server/compare/@standardnotes/scheduler-server@1.20.23...@standardnotes/scheduler-server@1.20.24) (2023-08-21)
### Bug Fixes
* **scheduler:** remove exit interview form link ([#702](https://github.com/standardnotes/server/issues/702)) ([3e56243](https://github.com/standardnotes/server/commit/3e56243d6f72f5ac86d4fb2191349ec3a589bc83))
## [1.20.23](https://github.com/standardnotes/server/compare/@standardnotes/scheduler-server@1.20.22...@standardnotes/scheduler-server@1.20.23) (2023-08-18)
**Note:** Version bump only for package @standardnotes/scheduler-server

View File

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

View File

@@ -15,8 +15,6 @@ export const html = `<div>
are willing to pay for a product is most crucial for us as we continue to evolve and iterate on Standard
Notes.
</p>
<p>If you have a minute, please fill out this brief exit interview: </p>
<a href="https://standardnotes.typeform.com/to/dX5lzPtm">Short Exit Interview →</a>
<p>
Our team reads every single response, and your feedback will be shared with the relevant department within our
team.

View File

@@ -3,6 +3,16 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.81.0](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.80.0...@standardnotes/syncing-server@1.81.0) (2023-08-21)
### Bug Fixes
* **syncing-server:** DocumentDB retry writes support ([#703](https://github.com/standardnotes/syncing-server-js/issues/703)) ([15a7f0e](https://github.com/standardnotes/syncing-server-js/commit/15a7f0e71ac2f6c355fb73208559a8fd822773aa))
### Features
* **syncing-server:** add use case for migrating items from one database to another ([#701](https://github.com/standardnotes/syncing-server-js/issues/701)) ([032fcb9](https://github.com/standardnotes/syncing-server-js/commit/032fcb938d9f81381dd9879af4bda9254ee8c499))
# [1.80.0](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.79.1...@standardnotes/syncing-server@1.80.0) (2023-08-18)
### Features

View File

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

View File

@@ -156,6 +156,7 @@ import { MongoDBItemPersistenceMapper } from '../Mapping/Persistence/MongoDB/Mon
import { Logger } from 'winston'
import { ItemRepositoryResolverInterface } from '../Domain/Item/ItemRepositoryResolverInterface'
import { TypeORMItemRepositoryResolver } from '../Infra/TypeORM/TypeORMItemRepositoryResolver'
import { TransitionItemsFromPrimaryToSecondaryDatabaseForUser } from '../Domain/UseCase/Transition/TransitionItemsFromPrimaryToSecondaryDatabaseForUser/TransitionItemsFromPrimaryToSecondaryDatabaseForUser'
export class ContainerConfigLoader {
private readonly DEFAULT_CONTENT_SIZE_TRANSFER_LIMIT = 10_000_000
@@ -775,6 +776,15 @@ export class ContainerConfigLoader {
container.get<SharedVaultRepositoryInterface>(TYPES.Sync_SharedVaultRepository),
),
)
container
.bind(TransitionItemsFromPrimaryToSecondaryDatabaseForUser)
.toConstantValue(
new TransitionItemsFromPrimaryToSecondaryDatabaseForUser(
container.get<ItemRepositoryInterface>(TYPES.Sync_MySQLItemRepository),
isSecondaryDatabaseEnabled ? container.get<ItemRepositoryInterface>(TYPES.Sync_MongoDBItemRepository) : null,
container.get<Logger>(TYPES.Sync_Logger),
),
)
// Services
container

View File

@@ -58,6 +58,7 @@ export class AppDataSource {
password: this.env.get('MONGO_PASSWORD', true),
database: this.env.get('MONGO_DATABASE'),
entities: [MongoDBItem],
retryWrites: false,
synchronize: true,
})

View File

@@ -82,6 +82,9 @@ const TYPES = {
Sync_DetermineSharedVaultOperationOnItem: Symbol.for('Sync_DetermineSharedVaultOperationOnItem'),
Sync_UpdateStorageQuotaUsedInSharedVault: Symbol.for('Sync_UpdateStorageQuotaUsedInSharedVault'),
Sync_AddNotificationsForUsers: Symbol.for('Sync_AddNotificationsForUsers'),
Sync_TransitionItemsFromPrimaryToSecondaryDatabaseForUser: Symbol.for(
'Sync_TransitionItemsFromPrimaryToSecondaryDatabaseForUser',
),
// Handlers
Sync_AccountDeletionRequestedEventHandler: Symbol.for('Sync_AccountDeletionRequestedEventHandler'),
Sync_DuplicateItemSyncedEventHandler: Symbol.for('Sync_DuplicateItemSyncedEventHandler'),

View File

@@ -237,4 +237,118 @@ describe('Item', () => {
expect(entity.props.keySystemAssociation).toBeUndefined()
expect(entity.getChanges()).toHaveLength(1)
})
it('should tell if an item is identical to another item', () => {
const entity = Item.create(
{
duplicateOf: null,
itemsKeyId: 'items-key-id',
content: 'content',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: 'enc-item-key',
authHash: 'auth-hash',
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
deleted: false,
updatedWithSession: null,
dates: Dates.create(new Date(123), new Date(123)).getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
).getValue()
const otherEntity = Item.create(
{
duplicateOf: null,
itemsKeyId: 'items-key-id',
content: 'content',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: 'enc-item-key',
authHash: 'auth-hash',
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
deleted: false,
updatedWithSession: null,
dates: Dates.create(new Date(123), new Date(123)).getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
).getValue()
expect(entity.isIdenticalTo(otherEntity)).toBeTruthy()
})
it('should tell that an item is not identical to another item', () => {
const entity = Item.create(
{
duplicateOf: null,
itemsKeyId: 'items-key-id',
content: 'content',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: 'enc-item-key',
authHash: 'auth-hash',
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
deleted: false,
updatedWithSession: null,
dates: Dates.create(new Date(123), new Date(123)).getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
).getValue()
const otherEntity = Item.create(
{
duplicateOf: null,
itemsKeyId: 'items-key-id',
content: 'content',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: 'enc-item-key',
authHash: 'auth-hash',
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
deleted: false,
updatedWithSession: null,
dates: Dates.create(new Date(123), new Date(124)).getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
).getValue()
expect(entity.isIdenticalTo(otherEntity)).toBeFalsy()
})
it('should tell that an item is not identical to another item if their ids are different', () => {
const entity = Item.create(
{
duplicateOf: null,
itemsKeyId: 'items-key-id',
content: 'content',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: 'enc-item-key',
authHash: 'auth-hash',
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
deleted: false,
updatedWithSession: null,
dates: Dates.create(new Date(123), new Date(123)).getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
).getValue()
const otherEntity = Item.create(
{
duplicateOf: null,
itemsKeyId: 'items-key-id',
content: 'content',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: 'enc-item-key',
authHash: 'auth-hash',
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
deleted: false,
updatedWithSession: null,
dates: Dates.create(new Date(123), new Date(124)).getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000001'),
).getValue()
expect(entity.isIdenticalTo(otherEntity)).toBeFalsy()
})
})

View File

@@ -108,4 +108,18 @@ export class Item extends Aggregate<ItemProps> {
)
this.props.keySystemAssociation = undefined
}
isIdenticalTo(item: Item): boolean {
if (this._id.toString() !== item._id.toString()) {
return false
}
const stringifiedThis = JSON.stringify(this.props)
const stringifiedItem = JSON.stringify(item.props)
const base64This = Buffer.from(stringifiedThis).toString('base64')
const base64Item = Buffer.from(stringifiedItem).toString('base64')
return base64This === base64Item
}
}

View File

@@ -0,0 +1,358 @@
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'
describe('TransitionItemsFromPrimaryToSecondaryDatabaseForUser', () => {
let primaryItemRepository: ItemRepositoryInterface
let secondaryItemRepository: ItemRepositoryInterface | null
let logger: Logger
let primaryItem1: Item
let primaryItem2: Item
let secondaryItem1: Item
let secondaryItem2: Item
const createUseCase = () =>
new TransitionItemsFromPrimaryToSecondaryDatabaseForUser(primaryItemRepository, secondaryItemRepository, logger)
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().mockResolvedValue(2)
secondaryItemRepository.findByUuid = jest
.fn()
.mockResolvedValueOnce(secondaryItem1)
.mockResolvedValueOnce(secondaryItem2)
logger = {} as jest.Mocked<Logger>
logger.error = jest.fn()
})
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(2)
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(result.getError()).toEqual(
'Item 00000000-0000-0000-0000-000000000001 is not identical in primary and secondary database',
)
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().mockResolvedValue(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(2)
expect(primaryItemRepository.countAll).toHaveBeenCalledWith({ userUuid: '00000000-0000-0000-0000-000000000000' })
expect((secondaryItemRepository as ItemRepositoryInterface).countAll).toHaveBeenCalledTimes(1)
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(2)
expect(primaryItemRepository.countAll).toHaveBeenCalledWith({ userUuid: '00000000-0000-0000-0000-000000000000' })
expect((secondaryItemRepository as ItemRepositoryInterface).countAll).toHaveBeenCalledTimes(1)
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().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)
})
})
})

View File

@@ -0,0 +1,140 @@
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'
export class TransitionItemsFromPrimaryToSecondaryDatabaseForUser implements UseCaseInterface<void> {
constructor(
private primaryItemRepository: ItemRepositoryInterface,
private secondaryItemRepository: ItemRepositoryInterface | null,
private logger: Logger,
) {}
async execute(dto: TransitionItemsFromPrimaryToSecondaryDatabaseForUserDTO): Promise<Result<void>> {
if (this.secondaryItemRepository === null) {
return Result.fail('Secondary item repository is not set')
}
const userUuidOrError = Uuid.create(dto.userUuid)
if (userUuidOrError.isFailed()) {
return Result.fail(userUuidOrError.getError())
}
const userUuid = userUuidOrError.getValue()
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()}`,
)
}
return Result.fail(migrationResult.getError())
}
const integrityCheckResult = await this.checkIntegrityBetweenPrimaryAndSecondaryDatabase(userUuid)
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()}`,
)
}
return Result.fail(integrityCheckResult.getError())
}
const cleanupResult = await this.deleteItemsForUser(userUuid, this.primaryItemRepository)
if (cleanupResult.isFailed()) {
this.logger.error(
`Failed to clean up primary database items for user ${userUuid.value}: ${cleanupResult.getError()}`,
)
}
return Result.ok()
}
private async migrateItemsForUser(userUuid: Uuid): Promise<Result<void>> {
try {
const totalItemsCountForUser = await this.primaryItemRepository.countAll({ userUuid: userUuid.value })
const pageSize = 1
const totalPages = totalItemsCountForUser
let currentPage = 1
for (currentPage; currentPage <= totalPages; currentPage++) {
const query: ItemQuery = {
userUuid: userUuid.value,
offset: currentPage - 1,
limit: pageSize,
}
const items = await this.primaryItemRepository.findAll(query)
for (const item of items) {
await (this.secondaryItemRepository as ItemRepositoryInterface).save(item)
}
}
return Result.ok()
} catch (error) {
return Result.fail((error as Error).message)
}
}
private async deleteItemsForUser(userUuid: Uuid, itemRepository: ItemRepositoryInterface): Promise<Result<void>> {
try {
await itemRepository.deleteByUserUuid(userUuid.value)
return Result.ok()
} catch (error) {
return Result.fail((error as Error).message)
}
}
private async checkIntegrityBetweenPrimaryAndSecondaryDatabase(userUuid: Uuid): Promise<Result<boolean>> {
try {
const totalItemsCountForUserInPrimary = await this.primaryItemRepository.countAll({ userUuid: userUuid.value })
const totalItemsCountForUserInSecondary = await (
this.secondaryItemRepository as ItemRepositoryInterface
).countAll({
userUuid: userUuid.value,
})
if (totalItemsCountForUserInPrimary !== 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})`,
)
}
const pageSize = 1
const totalPages = totalItemsCountForUserInPrimary
let currentPage = 1
for (currentPage; currentPage <= totalPages; currentPage++) {
const query: ItemQuery = {
userUuid: userUuid.value,
offset: currentPage - 1,
limit: pageSize,
}
const items = await this.primaryItemRepository.findAll(query)
for (const item of items) {
const itemInSecondary = await (this.secondaryItemRepository as ItemRepositoryInterface).findByUuid(item.uuid)
if (!itemInSecondary) {
return Result.fail(`Item ${item.uuid.value} not found in secondary database`)
}
if (!item.isIdenticalTo(itemInSecondary)) {
return Result.fail(`Item ${item.uuid.value} is not identical in primary and secondary database`)
}
}
}
return Result.ok()
} catch (error) {
return Result.fail((error as Error).message)
}
}
}

View File

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