feat(revisions): add persisting revisions from s3 dump

This commit is contained in:
Karol Sójko
2022-11-21 12:56:17 +01:00
parent b0406dd8aa
commit 822ee890af
14 changed files with 283 additions and 31 deletions
+40 -11
View File
@@ -29,6 +29,12 @@ import { MySQLRevisionRepository } from '../Infra/MySQL/MySQLRevisionRepository'
import { RevisionMetadataPersistenceMapper } from '../Mapping/RevisionMetadataPersistenceMapper'
import { TypeORMRevision } from '../Infra/TypeORM/TypeORMRevision'
import { RevisionMetadata } from '../Domain/Revision/RevisionMetadata'
import { Revision } from '../Domain/Revision/Revision'
import { RevisionItemStringMapper } from '../Mapping/RevisionItemStringMapper'
import { RevisionPersistenceMapper } from '../Mapping/RevisionPersistenceMapper'
import { ItemDumpedEventHandler } from '../Domain/Handler/ItemDumpedEventHandler'
import { DumpRepositoryInterface } from '../Domain/Dump/DumpRepositoryInterface'
import { S3DumpRepository } from '../Infra/S3/S3ItemDumpRepository'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const newrelicFormatter = require('@newrelic/winston-enricher')
@@ -88,22 +94,18 @@ export class ContainerConfigLoader {
container
.bind<MapperInterface<RevisionMetadata, TypeORMRevision>>(TYPES.RevisionMetadataPersistenceMapper)
.toConstantValue(new RevisionMetadataPersistenceMapper())
container
.bind<MapperInterface<Revision, TypeORMRevision>>(TYPES.RevisionPersistenceMapper)
.toConstantValue(new RevisionPersistenceMapper())
container
.bind<MapperInterface<Revision, string>>(TYPES.RevisionItemStringMapper)
.toConstantValue(new RevisionItemStringMapper())
// ORM
container
.bind<Repository<TypeORMRevision>>(TYPES.ORMRevisionRepository)
.toConstantValue(AppDataSource.getRepository(TypeORMRevision))
// Repositories
container
.bind<RevisionRepositoryInterface>(TYPES.RevisionRepository)
.toConstantValue(
new MySQLRevisionRepository(
container.get(TYPES.ORMRevisionRepository),
container.get(TYPES.RevisionMetadataPersistenceMapper),
),
)
// env vars
container.bind(TYPES.REDIS_URL).toConstantValue(env.get('REDIS_URL'))
container.bind(TYPES.SQS_QUEUE_URL).toConstantValue(env.get('SQS_QUEUE_URL', true))
@@ -114,6 +116,26 @@ export class ContainerConfigLoader {
container.bind(TYPES.NEW_RELIC_ENABLED).toConstantValue(env.get('NEW_RELIC_ENABLED', true))
container.bind(TYPES.VERSION).toConstantValue(env.get('VERSION'))
// Repositories
container
.bind<RevisionRepositoryInterface>(TYPES.RevisionRepository)
.toConstantValue(
new MySQLRevisionRepository(
container.get(TYPES.ORMRevisionRepository),
container.get(TYPES.RevisionMetadataPersistenceMapper),
container.get(TYPES.RevisionPersistenceMapper),
),
)
container
.bind<DumpRepositoryInterface>(TYPES.DumpRepository)
.toConstantValue(
new S3DumpRepository(
container.get(TYPES.S3_BACKUP_BUCKET_NAME),
container.get(TYPES.S3),
container.get(TYPES.RevisionItemStringMapper),
),
)
// use cases
container
.bind<GetRevisionsMetada>(TYPES.GetRevisionsMetada)
@@ -125,6 +147,11 @@ export class ContainerConfigLoader {
.toConstantValue(new RevisionsController(container.get(TYPES.GetRevisionsMetada), container.get(TYPES.Logger)))
// Handlers
container
.bind<ItemDumpedEventHandler>(TYPES.ItemDumpedEventHandler)
.toConstantValue(
new ItemDumpedEventHandler(container.get(TYPES.DumpRepository), container.get(TYPES.RevisionRepository)),
)
// Services
container
@@ -136,7 +163,9 @@ export class ContainerConfigLoader {
.bind<InversifyExpressApiGatewayAuthMiddleware>(TYPES.ApiGatewayAuthMiddleware)
.to(InversifyExpressApiGatewayAuthMiddleware)
const eventHandlers: Map<string, DomainEventHandlerInterface> = new Map([])
const eventHandlers: Map<string, DomainEventHandlerInterface> = new Map([
['ITEM_DUMPED', container.get(TYPES.ItemDumpedEventHandler)],
])
if (env.get('SQS_QUEUE_URL', true)) {
container
@@ -6,10 +6,13 @@ const TYPES = {
S3: Symbol.for('S3'),
// Map
RevisionMetadataPersistenceMapper: Symbol.for('RevisionMetadataPersistenceMapper'),
RevisionPersistenceMapper: Symbol.for('RevisionPersistenceMapper'),
RevisionItemStringMapper: Symbol.for('RevisionItemStringMapper'),
// ORM
ORMRevisionRepository: Symbol.for('ORMRevisionRepository'),
// Repositories
RevisionRepository: Symbol.for('RevisionRepository'),
DumpRepository: Symbol.for('DumpRepository'),
// env vars
REDIS_URL: Symbol.for('REDIS_URL'),
SQS_QUEUE_URL: Symbol.for('SQS_QUEUE_URL'),
@@ -25,6 +28,7 @@ const TYPES = {
// Controller
RevisionsController: Symbol.for('RevisionsController'),
// Handlers
ItemDumpedEventHandler: Symbol.for('ItemDumpedEventHandler'),
// Services
CrossServiceTokenDecoder: Symbol.for('CrossServiceTokenDecoder'),
DomainEventSubscriberFactory: Symbol.for('DomainEventSubscriberFactory'),
@@ -0,0 +1,6 @@
import { Revision } from '../Revision/Revision'
export interface DumpRepositoryInterface {
getRevisionFromDumpPath(path: string): Promise<Revision | null>
removeDump(path: string): Promise<void>
}
@@ -0,0 +1,46 @@
import { ItemDumpedEvent } from '@standardnotes/domain-events'
import { DumpRepositoryInterface } from '../Dump/DumpRepositoryInterface'
import { Revision } from '../Revision/Revision'
import { RevisionRepositoryInterface } from '../Revision/RevisionRepositoryInterface'
import { ItemDumpedEventHandler } from './ItemDumpedEventHandler'
describe('ItemDumpedEventHandler', () => {
let dumpRepository: DumpRepositoryInterface
let revisionRepository: RevisionRepositoryInterface
let revision: Revision
let event: ItemDumpedEvent
const createHandler = () => new ItemDumpedEventHandler(dumpRepository, revisionRepository)
beforeEach(() => {
revision = {} as jest.Mocked<Revision>
dumpRepository = {} as jest.Mocked<DumpRepositoryInterface>
dumpRepository.getRevisionFromDumpPath = jest.fn().mockReturnValue(revision)
dumpRepository.removeDump = jest.fn()
revisionRepository = {} as jest.Mocked<RevisionRepositoryInterface>
revisionRepository.save = jest.fn()
event = {} as jest.Mocked<ItemDumpedEvent>
event.payload = {
fileDumpPath: 'foobar',
}
})
it('should save a revision from file dump', async () => {
await createHandler().handle(event)
expect(revisionRepository.save).toHaveBeenCalled()
expect(dumpRepository.removeDump).toHaveBeenCalled()
})
it('should not save a revision if it could not be created from dump', async () => {
dumpRepository.getRevisionFromDumpPath = jest.fn().mockReturnValue(null)
await createHandler().handle(event)
expect(revisionRepository.save).not.toHaveBeenCalled()
expect(dumpRepository.removeDump).toHaveBeenCalled()
})
})
@@ -0,0 +1,24 @@
import { DomainEventHandlerInterface, ItemDumpedEvent } from '@standardnotes/domain-events'
import { DumpRepositoryInterface } from '../Dump/DumpRepositoryInterface'
import { RevisionRepositoryInterface } from '../Revision/RevisionRepositoryInterface'
export class ItemDumpedEventHandler implements DomainEventHandlerInterface {
constructor(
private dumpRepository: DumpRepositoryInterface,
private revisionRepository: RevisionRepositoryInterface,
) {}
async handle(event: ItemDumpedEvent): Promise<void> {
const revision = await this.dumpRepository.getRevisionFromDumpPath(event.payload.fileDumpPath)
if (revision === null) {
await this.dumpRepository.removeDump(event.payload.fileDumpPath)
return
}
await this.revisionRepository.save(revision)
await this.dumpRepository.removeDump(event.payload.fileDumpPath)
}
}
@@ -1,4 +1,4 @@
import { Uuid } from '@standardnotes/domain-core'
import { Timestamps, Uuid } from '@standardnotes/domain-core'
import { ContentType } from './ContentType'
@@ -10,6 +10,5 @@ export interface RevisionProps {
encItemKey: string | null
authHash: string | null
creationDate: Date
createdAt: Date
updatedAt: Date
timestamps: Timestamps
}
@@ -1,7 +1,9 @@
import { Uuid } from '@standardnotes/domain-core'
import { Revision } from './Revision'
import { RevisionMetadata } from './RevisionMetadata'
export interface RevisionRepositoryInterface {
findMetadataByItemId(itemUuid: Uuid): Promise<Array<RevisionMetadata>>
save(revision: Revision): Promise<Revision>
}
@@ -1,5 +1,6 @@
import { MapperInterface, Uuid } from '@standardnotes/domain-core'
import { Repository } from 'typeorm'
import { Revision } from '../../Domain/Revision/Revision'
import { RevisionMetadata } from '../../Domain/Revision/RevisionMetadata'
import { RevisionRepositoryInterface } from '../../Domain/Revision/RevisionRepositoryInterface'
@@ -8,9 +9,18 @@ import { TypeORMRevision } from '../TypeORM/TypeORMRevision'
export class MySQLRevisionRepository implements RevisionRepositoryInterface {
constructor(
private ormRepository: Repository<TypeORMRevision>,
private revisionMapper: MapperInterface<RevisionMetadata, TypeORMRevision>,
private revisionMetadataMapper: MapperInterface<RevisionMetadata, TypeORMRevision>,
private revisionMapper: MapperInterface<Revision, TypeORMRevision>,
) {}
async save(revision: Revision): Promise<Revision> {
const typeormRevision = this.revisionMapper.toProjection(revision)
await this.ormRepository.save(typeormRevision)
return revision
}
async findMetadataByItemId(itemUuid: Uuid): Promise<Array<RevisionMetadata>> {
const queryBuilder = this.ormRepository
.createQueryBuilder()
@@ -27,7 +37,7 @@ export class MySQLRevisionRepository implements RevisionRepositoryInterface {
const metadata = []
for (const simplifiedRevision of simplifiedRevisions) {
metadata.push(this.revisionMapper.toDomain(simplifiedRevision))
metadata.push(this.revisionMetadataMapper.toDomain(simplifiedRevision))
}
return metadata
@@ -0,0 +1,39 @@
import { MapperInterface } from '@standardnotes/domain-core'
import { S3 } from 'aws-sdk'
import { DumpRepositoryInterface } from '../../Domain/Dump/DumpRepositoryInterface'
import { Revision } from '../../Domain/Revision/Revision'
export class S3DumpRepository implements DumpRepositoryInterface {
constructor(
private dumpBucketName: string,
private s3Client: S3,
private revisionStringItemMapper: MapperInterface<Revision, string>,
) {}
async getRevisionFromDumpPath(path: string): Promise<Revision | null> {
const s3Object = await this.s3Client
.getObject({
Bucket: this.dumpBucketName,
Key: path,
})
.promise()
if (s3Object.Body === undefined) {
return null
}
const revision = this.revisionStringItemMapper.toDomain(s3Object.Body as string)
return revision
}
async removeDump(path: string): Promise<void> {
await this.s3Client
.deleteObject({
Bucket: this.dumpBucketName,
Key: path,
})
.promise()
}
}
@@ -1,5 +1,3 @@
import { ContentType } from '@standardnotes/common'
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'
@Entity({ name: 'revisions' })
@@ -25,7 +23,7 @@ export class TypeORMRevision {
length: 255,
nullable: true,
})
declare contentType: ContentType | null
declare contentType: string | null
@Column({
type: 'varchar',
@@ -0,0 +1,43 @@
import { MapperInterface, Timestamps, Uuid } from '@standardnotes/domain-core'
import { ContentType } from '../Domain/Revision/ContentType'
import { Revision } from '../Domain/Revision/Revision'
export class RevisionItemStringMapper implements MapperInterface<Revision, string> {
toDomain(projection: string): Revision {
const item = JSON.parse(projection)
const contentTypeOrError = ContentType.create(item.content_type)
if (contentTypeOrError.isFailed()) {
throw new Error(`Could not map item string to revision: ${contentTypeOrError.getError()}`)
}
const contentType = contentTypeOrError.getValue()
const itemUuidOrError = Uuid.create(item.uuid)
if (itemUuidOrError.isFailed()) {
throw new Error(`Could not map item string to revision: ${itemUuidOrError.getError()}`)
}
const itemUuid = itemUuidOrError.getValue()
const revisionOrError = Revision.create({
itemUuid,
authHash: item.auth_hash,
content: item.content,
contentType,
itemsKeyId: item.items_key_id,
encItemKey: item.enc_item_key,
creationDate: new Date(),
timestamps: Timestamps.create(new Date(), new Date()).getValue(),
})
if (revisionOrError.isFailed()) {
throw new Error(`Could not map item string to revision: ${revisionOrError.getError()}`)
}
return revisionOrError.getValue()
}
toProjection(domain: Revision): string {
return JSON.stringify(domain)
}
}
@@ -0,0 +1,62 @@
import { MapperInterface, Timestamps, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
import { ContentType } from '../Domain/Revision/ContentType'
import { Revision } from '../Domain/Revision/Revision'
import { TypeORMRevision } from '../Infra/TypeORM/TypeORMRevision'
export class RevisionPersistenceMapper implements MapperInterface<Revision, TypeORMRevision> {
toDomain(projection: TypeORMRevision): Revision {
const contentTypeOrError = ContentType.create(projection.contentType)
if (contentTypeOrError.isFailed()) {
throw new Error(`Could not map typeorm revision to domain revision: ${contentTypeOrError.getError()}`)
}
const contentType = contentTypeOrError.getValue()
const timestampsOrError = Timestamps.create(projection.createdAt, projection.updatedAt)
if (timestampsOrError.isFailed()) {
throw new Error(`Could not map typeorm revision to domain revision: ${timestampsOrError.getError()}`)
}
const timestamps = timestampsOrError.getValue()
const itemUuidOrError = Uuid.create(projection.itemUuid)
if (itemUuidOrError.isFailed()) {
throw new Error(`Could not map typeorm revision to domain revision: ${itemUuidOrError.getError()}`)
}
const itemUuid = itemUuidOrError.getValue()
const revisionOrError = Revision.create(
{
authHash: projection.authHash,
content: projection.content,
contentType,
creationDate: projection.creationDate,
encItemKey: projection.encItemKey,
itemsKeyId: projection.itemsKeyId,
itemUuid,
timestamps,
},
new UniqueEntityId(projection.uuid),
)
if (revisionOrError.isFailed()) {
throw new Error(`Could not map typeorm revision to domain revision: ${revisionOrError.getError()}`)
}
return revisionOrError.getValue()
}
toProjection(domain: Revision): TypeORMRevision {
const typeormRevision = new TypeORMRevision()
typeormRevision.authHash = domain.props.authHash
typeormRevision.content = domain.props.content
typeormRevision.contentType = domain.props.contentType.value
typeormRevision.createdAt = domain.props.timestamps.createdAt
typeormRevision.updatedAt = domain.props.timestamps.updatedAt
typeormRevision.creationDate = domain.props.creationDate
typeormRevision.encItemKey = domain.props.encItemKey
typeormRevision.itemUuid = domain.props.itemUuid.value
typeormRevision.itemsKeyId = domain.props.itemsKeyId
typeormRevision.uuid = domain.id.toString()
return typeormRevision
}
}
@@ -5,16 +5,15 @@ import {
DomainEventService,
ItemRevisionCreationRequestedEvent,
} from '@standardnotes/domain-events'
import { Item } from '../Item/Item'
import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface'
import { ItemRevisionCreationRequestedEventHandler } from './ItemRevisionCreationRequestedEventHandler'
import { RevisionServiceInterface } from '../Revision/RevisionServiceInterface'
import { ItemBackupServiceInterface } from '../Item/ItemBackupServiceInterface'
import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface'
describe('ItemRevisionCreationRequestedEventHandler', () => {
let itemRepository: ItemRepositoryInterface
let revisionService: RevisionServiceInterface
let event: ItemRevisionCreationRequestedEvent
let item: Item
let itemBackupService: ItemBackupServiceInterface
@@ -24,7 +23,6 @@ describe('ItemRevisionCreationRequestedEventHandler', () => {
const createHandler = () =>
new ItemRevisionCreationRequestedEventHandler(
itemRepository,
revisionService,
itemBackupService,
domainEventFactory,
domainEventPublisher,
@@ -39,9 +37,6 @@ describe('ItemRevisionCreationRequestedEventHandler', () => {
itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
itemRepository.findByUuid = jest.fn().mockReturnValue(item)
revisionService = {} as jest.Mocked<RevisionServiceInterface>
revisionService.createRevision = jest.fn()
event = {} as jest.Mocked<ItemRevisionCreationRequestedEvent>
event.createdAt = new Date(1)
event.payload = {
@@ -68,7 +63,6 @@ describe('ItemRevisionCreationRequestedEventHandler', () => {
it('should create a revision for an item', async () => {
await createHandler().handle(event)
expect(revisionService.createRevision).toHaveBeenCalled()
expect(domainEventPublisher.publish).toHaveBeenCalled()
expect(domainEventFactory.createItemDumpedEvent).toHaveBeenCalled()
})
@@ -78,7 +72,7 @@ describe('ItemRevisionCreationRequestedEventHandler', () => {
await createHandler().handle(event)
expect(revisionService.createRevision).not.toHaveBeenCalled()
expect(domainEventPublisher.publish).not.toHaveBeenCalled()
})
it('should not create a revision for an item if the dump was not created', async () => {
@@ -9,13 +9,11 @@ import TYPES from '../../Bootstrap/Types'
import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface'
import { ItemBackupServiceInterface } from '../Item/ItemBackupServiceInterface'
import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface'
import { RevisionServiceInterface } from '../Revision/RevisionServiceInterface'
@injectable()
export class ItemRevisionCreationRequestedEventHandler implements DomainEventHandlerInterface {
constructor(
@inject(TYPES.ItemRepository) private itemRepository: ItemRepositoryInterface,
@inject(TYPES.RevisionService) private revisionService: RevisionServiceInterface,
@inject(TYPES.ItemBackupService) private itemBackupService: ItemBackupServiceInterface,
@inject(TYPES.DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface,
@inject(TYPES.DomainEventPublisher) private domainEventPublisher: DomainEventPublisherInterface,
@@ -33,7 +31,5 @@ export class ItemRevisionCreationRequestedEventHandler implements DomainEventHan
this.domainEventFactory.createItemDumpedEvent(fileDumpPath, event.meta.correlation.userIdentifier),
)
}
await this.revisionService.createRevision(item)
}
}