diff --git a/.github/workflows/revisions.yml b/.github/workflows/revisions.yml new file mode 100644 index 000000000..84b1f39a8 --- /dev/null +++ b/.github/workflows/revisions.yml @@ -0,0 +1,46 @@ +name: Revisions + +concurrency: + group: revisions + cancel-in-progress: true + +on: + push: + tags: + - '*standardnotes/revisions-server*' + workflow_dispatch: + +jobs: + call_server_application_workflow: + name: Server Application + uses: standardnotes/server/.github/workflows/common-server-application.yml@main + with: + service_name: revisions + workspace_name: "@standardnotes/revisions-server" + e2e_tag_parameter_name: revisions_image_tag + package_path: packages/revisions + secrets: inherit + + newrelic: + needs: call_server_application_workflow + + runs-on: ubuntu-latest + steps: + - name: Create New Relic deployment marker for Web + uses: newrelic/deployment-marker-action@v1 + with: + accountId: ${{ secrets.NEW_RELIC_ACCOUNT_ID }} + apiKey: ${{ secrets.NEW_RELIC_API_KEY }} + applicationId: ${{ secrets.NEW_RELIC_APPLICATION_ID_REVISIONS_WEB_PROD }} + revision: "${{ github.sha }}" + description: "Automated Deployment via Github Actions" + user: "${{ github.actor }}" + - name: Create New Relic deployment marker for Worker + uses: newrelic/deployment-marker-action@v1 + with: + accountId: ${{ secrets.NEW_RELIC_ACCOUNT_ID }} + apiKey: ${{ secrets.NEW_RELIC_API_KEY }} + applicationId: ${{ secrets.NEW_RELIC_APPLICATION_ID_REVISIONS_WORKER_PROD }} + revision: "${{ github.sha }}" + description: "Automated Deployment via Github Actions" + user: "${{ github.actor }}" diff --git a/.pnp.cjs b/.pnp.cjs index 49eb3433d..e27765ac9 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -53,6 +53,10 @@ const RAW_RUNTIME_STATE = "name": "@standardnotes/predicates",\ "reference": "workspace:packages/predicates"\ },\ + {\ + "name": "@standardnotes/revisions-server",\ + "reference": "workspace:packages/revisions"\ + },\ {\ "name": "@standardnotes/scheduler-server",\ "reference": "workspace:packages/scheduler"\ @@ -99,6 +103,7 @@ const RAW_RUNTIME_STATE = ["@standardnotes/event-store", ["workspace:packages/event-store"]],\ ["@standardnotes/files-server", ["workspace:packages/files"]],\ ["@standardnotes/predicates", ["workspace:packages/predicates"]],\ + ["@standardnotes/revisions-server", ["workspace:packages/revisions"]],\ ["@standardnotes/scheduler-server", ["workspace:packages/scheduler"]],\ ["@standardnotes/security", ["workspace:packages/security"]],\ ["@standardnotes/server-monorepo", ["workspace:."]],\ @@ -3004,6 +3009,52 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["@standardnotes/revisions-server", [\ + ["workspace:packages/revisions", {\ + "packageLocation": "./packages/revisions/",\ + "packageDependencies": [\ + ["@standardnotes/revisions-server", "workspace:packages/revisions"],\ + ["@newrelic/native-metrics", "npm:9.0.0"],\ + ["@newrelic/winston-enricher", "virtual:c66bf20e88479ada0172094776519a9f51acc4731d22079b60a295bcec7ea42d5545cbce58a77a50d932bf953298799135e99707486e343da6d99ba1d167bdbd#npm:4.0.0"],\ + ["@sentry/node", "npm:7.19.0"],\ + ["@standardnotes/api", "npm:1.19.0"],\ + ["@standardnotes/common", "workspace:packages/common"],\ + ["@standardnotes/domain-core", "workspace:packages/domain-core"],\ + ["@standardnotes/domain-events", "workspace:packages/domain-events"],\ + ["@standardnotes/domain-events-infra", "workspace:packages/domain-events-infra"],\ + ["@standardnotes/security", "workspace:packages/security"],\ + ["@standardnotes/time", "workspace:packages/time"],\ + ["@types/cors", "npm:2.8.12"],\ + ["@types/dotenv", "npm:8.2.0"],\ + ["@types/express", "npm:4.17.14"],\ + ["@types/inversify-express-utils", "npm:2.0.0"],\ + ["@types/ioredis", "npm:5.0.0"],\ + ["@types/jest", "npm:29.1.1"],\ + ["@types/newrelic", "npm:7.0.4"],\ + ["@typescript-eslint/eslint-plugin", "virtual:04783e12400851b8a3d76e71495851cc94959db6e62f04cb0a31190080629440b182d8c8eb4d7f2b04e281912f2783a5fd4d2c3c6ab68d38b7097246c93f4c19#npm:5.40.1"],\ + ["aws-sdk", "npm:2.1253.0"],\ + ["cors", "npm:2.8.5"],\ + ["dotenv", "npm:16.0.1"],\ + ["eslint", "npm:8.25.0"],\ + ["eslint-plugin-prettier", "virtual:c66bf20e88479ada0172094776519a9f51acc4731d22079b60a295bcec7ea42d5545cbce58a77a50d932bf953298799135e99707486e343da6d99ba1d167bdbd#npm:4.2.1"],\ + ["express", "npm:4.18.2"],\ + ["helmet", "npm:6.0.0"],\ + ["inversify", "npm:6.0.1"],\ + ["inversify-express-utils", "npm:6.4.3"],\ + ["ioredis", "npm:5.2.4"],\ + ["jest", "virtual:fd909b174d079e30b336c4ce72c38a88c1e447767b1a8dd7655e07719a1e31b97807f0931368724fc78897ff15e6a6d00b83316c0f76d11f85111f342e08bb79#npm:29.1.2"],\ + ["mysql2", "npm:2.3.3"],\ + ["newrelic", "npm:9.6.0"],\ + ["npm-check-updates", "npm:16.0.1"],\ + ["reflect-metadata", "npm:0.1.13"],\ + ["ts-jest", "virtual:fd909b174d079e30b336c4ce72c38a88c1e447767b1a8dd7655e07719a1e31b97807f0931368724fc78897ff15e6a6d00b83316c0f76d11f85111f342e08bb79#npm:29.0.3"],\ + ["typeorm", "virtual:c66bf20e88479ada0172094776519a9f51acc4731d22079b60a295bcec7ea42d5545cbce58a77a50d932bf953298799135e99707486e343da6d99ba1d167bdbd#npm:0.3.10"],\ + ["typescript", "patch:typescript@npm%3A4.8.4#optional!builtin::version=4.8.4&hash=701156"],\ + ["winston", "npm:3.8.2"]\ + ],\ + "linkType": "SOFT"\ + }]\ + ]],\ ["@standardnotes/scheduler-server", [\ ["workspace:packages/scheduler", {\ "packageLocation": "./packages/scheduler/",\ diff --git a/package.json b/package.json index 65abeed59..60f2dc30d 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "lint:websockets": "yarn workspace @standardnotes/websockets-server lint", "lint:workspace": "yarn workspace @standardnotes/workspace-server lint", "lint:analytics": "yarn workspace @standardnotes/analytics lint", + "lint:revisions": "yarn workspace @standardnotes/revisions-server lint", "clean": "yarn workspaces foreach -p --verbose run clean", "setup:env": "cp .env.sample .env && yarn workspaces foreach -p --verbose run setup:env", "start:auth": "yarn workspace @standardnotes/auth-server start", @@ -34,6 +35,7 @@ "start:websockets": "yarn workspace @standardnotes/websockets-server start", "start:workspace": "yarn workspace @standardnotes/workspace-server start", "start:analytics": "yarn workspace @standardnotes/analytics worker", + "start:revisions": "yarn workspace @standardnotes/revisions-server start", "release": "lerna version --conventional-graduate --conventional-commits --yes -m \"chore(release): publish new version\"", "publish": "lerna publish from-git --yes --no-verify-access --loglevel verbose", "postversion": "./scripts/push-tags-one-by-one.sh", diff --git a/packages/domain-core/src/Domain/Common/Timestamps.spec.ts b/packages/domain-core/src/Domain/Common/Timestamps.spec.ts new file mode 100644 index 000000000..98839a122 --- /dev/null +++ b/packages/domain-core/src/Domain/Common/Timestamps.spec.ts @@ -0,0 +1,21 @@ +import { Timestamps } from './Timestamps' + +describe('Timestamps', () => { + it('should create a value object', () => { + const valueOrError = Timestamps.create(new Date(1), new Date(2)) + + expect(valueOrError.isFailed()).toBeFalsy() + expect(valueOrError.getValue().createdAt).toEqual(new Date(1)) + expect(valueOrError.getValue().updatedAt).toEqual(new Date(2)) + }) + + it('should not create an invalid value object', () => { + let valueOrError = Timestamps.create(null as unknown as Date, '2' as unknown as Date) + + expect(valueOrError.isFailed()).toBeTruthy() + + valueOrError = Timestamps.create(new Date(2), '2' as unknown as Date) + + expect(valueOrError.isFailed()).toBeTruthy() + }) +}) diff --git a/packages/domain-core/src/Domain/Common/Timestamps.ts b/packages/domain-core/src/Domain/Common/Timestamps.ts new file mode 100644 index 000000000..5fc42c510 --- /dev/null +++ b/packages/domain-core/src/Domain/Common/Timestamps.ts @@ -0,0 +1,32 @@ +import { Result } from '../Core/Result' +import { ValueObject } from '../Core/ValueObject' +import { TimestampsProps } from './TimestampsProps' + +export class Timestamps extends ValueObject { + get createdAt(): Date { + return this.props.createdAt + } + + get updatedAt(): Date { + return this.props.updatedAt + } + + private constructor(props: TimestampsProps) { + super(props) + } + + static create(createdAt: Date, updatedAt: Date): Result { + if (!(createdAt instanceof Date)) { + return Result.fail( + `Could not create Timestamps. Creation date should be a date object, given: ${createdAt}`, + ) + } + if (!(updatedAt instanceof Date)) { + return Result.fail( + `Could not create Timestamps. Update date should be a date object, given: ${createdAt}`, + ) + } + + return Result.ok(new Timestamps({ createdAt, updatedAt })) + } +} diff --git a/packages/domain-core/src/Domain/Common/TimestampsProps.ts b/packages/domain-core/src/Domain/Common/TimestampsProps.ts new file mode 100644 index 000000000..de942dd98 --- /dev/null +++ b/packages/domain-core/src/Domain/Common/TimestampsProps.ts @@ -0,0 +1,4 @@ +export interface TimestampsProps { + createdAt: Date + updatedAt: Date +} diff --git a/packages/domain-core/src/Domain/Common/Uuid.spec.ts b/packages/domain-core/src/Domain/Common/Uuid.spec.ts index a5665b690..bd4ea6cd4 100644 --- a/packages/domain-core/src/Domain/Common/Uuid.spec.ts +++ b/packages/domain-core/src/Domain/Common/Uuid.spec.ts @@ -2,14 +2,14 @@ import { Uuid } from './Uuid' describe('Uuid', () => { it('should create a value object', () => { - const valueOrError = Uuid.create('1-2-3') + const valueOrError = Uuid.create('84c0f8e8-544a-4c7e-9adf-26209303bc1d') expect(valueOrError.isFailed()).toBeFalsy() - expect(valueOrError.getValue().value).toEqual('1-2-3') + expect(valueOrError.getValue().value).toEqual('84c0f8e8-544a-4c7e-9adf-26209303bc1d') }) it('should not create an invalid value object', () => { - const valueOrError = Uuid.create('') + const valueOrError = Uuid.create('1-2-3') expect(valueOrError.isFailed()).toBeTruthy() }) diff --git a/packages/domain-core/src/Domain/Core/Validator.spec.ts b/packages/domain-core/src/Domain/Core/Validator.spec.ts index eac99995a..46d4ebd01 100644 --- a/packages/domain-core/src/Domain/Core/Validator.spec.ts +++ b/packages/domain-core/src/Domain/Core/Validator.spec.ts @@ -20,13 +20,13 @@ describe('Validator', () => { it('should validate proper uuids', () => { for (const validUuid of validUuids) { - expect(Validator.isValidUuid(validUuid)).toBeTruthy() + expect(Validator.isValidUuid(validUuid).isFailed()).toBeFalsy() } }) it('should not validate invalid uuids', () => { for (const invalidUuid of invalidUuids) { - expect(Validator.isValidUuid(invalidUuid as string)).toBeFalsy() + expect(Validator.isValidUuid(invalidUuid as string).isFailed()).toBeTruthy() } }) }) diff --git a/packages/domain-core/src/Domain/Map/MapInterface.ts b/packages/domain-core/src/Domain/Map/MapInterface.ts deleted file mode 100644 index 5f3890a40..000000000 --- a/packages/domain-core/src/Domain/Map/MapInterface.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface MapInterface { - toDomain(persistence: U): T - toProjection(domain: T): U -} diff --git a/packages/domain-core/src/Domain/Mapping/MapperInterface.ts b/packages/domain-core/src/Domain/Mapping/MapperInterface.ts new file mode 100644 index 000000000..4e42966a6 --- /dev/null +++ b/packages/domain-core/src/Domain/Mapping/MapperInterface.ts @@ -0,0 +1,4 @@ +export interface MapperInterface { + toDomain(projection: U): T + toProjection(domain: T): U +} diff --git a/packages/domain-core/src/Domain/Revision/RevisionMetadata.ts b/packages/domain-core/src/Domain/Revision/RevisionMetadata.ts new file mode 100644 index 000000000..e49dbe51a --- /dev/null +++ b/packages/domain-core/src/Domain/Revision/RevisionMetadata.ts @@ -0,0 +1,19 @@ +import { Entity } from '../Core/Entity' +import { Result } from '../Core/Result' +import { UniqueEntityId } from '../Core/UniqueEntityId' + +import { RevisionMetadataProps } from './RevisionMetadataProps' + +export class RevisionMetadata extends Entity { + get id(): UniqueEntityId { + return this._id + } + + private constructor(props: RevisionMetadataProps, id?: UniqueEntityId) { + super(props, id) + } + + static create(props: RevisionMetadataProps, id?: UniqueEntityId): Result { + return Result.ok(new RevisionMetadata(props, id)) + } +} diff --git a/packages/domain-core/src/Domain/Revision/RevisionMetadataProps.ts b/packages/domain-core/src/Domain/Revision/RevisionMetadataProps.ts new file mode 100644 index 000000000..ab3b6e226 --- /dev/null +++ b/packages/domain-core/src/Domain/Revision/RevisionMetadataProps.ts @@ -0,0 +1,8 @@ +import { Timestamps } from '../Common/Timestamps' + +import { ContentType } from './ContentType' + +export interface RevisionMetadataProps { + contentType: ContentType + timestamps: Timestamps +} diff --git a/packages/domain-core/src/Domain/UseCase/UseCaseInterface.ts b/packages/domain-core/src/Domain/UseCase/UseCaseInterface.ts new file mode 100644 index 000000000..bd4b0ed9e --- /dev/null +++ b/packages/domain-core/src/Domain/UseCase/UseCaseInterface.ts @@ -0,0 +1,5 @@ +import { Result } from '../Core/Result' + +export interface UseCaseInterface { + execute(...args: any[]): Promise> +} diff --git a/packages/domain-core/src/Domain/index.ts b/packages/domain-core/src/Domain/index.ts index 5dbf6b75f..79f1d670f 100644 --- a/packages/domain-core/src/Domain/index.ts +++ b/packages/domain-core/src/Domain/index.ts @@ -1,5 +1,7 @@ export * from './Common/Email' export * from './Common/EmailProps' +export * from './Common/Timestamps' +export * from './Common/TimestampsProps' export * from './Common/Uuid' export * from './Common/UuidProps' @@ -12,9 +14,13 @@ export * from './Core/Validator' export * from './Core/ValueObject' export * from './Core/ValueObjectProps' -export * from './Map/MapInterface' +export * from './Mapping/MapperInterface' export * from './Revision/ContentType' export * from './Revision/ContentTypeProps' export * from './Revision/Revision' +export * from './Revision/RevisionMetadata' +export * from './Revision/RevisionMetadataProps' export * from './Revision/RevisionProps' + +export * from './UseCase/UseCaseInterface' diff --git a/packages/revisions/.env.sample b/packages/revisions/.env.sample new file mode 100644 index 000000000..0684c152e --- /dev/null +++ b/packages/revisions/.env.sample @@ -0,0 +1,34 @@ +LOG_LEVEL=info +NODE_ENV=development +VERSION=development + +AUTH_JWT_SECRET=auth_jwt_secret + +PORT=3000 + +DB_HOST=db +DB_REPLICA_HOST=db +DB_PORT=3306 +DB_USERNAME=std_notes_user +DB_PASSWORD=changeme123 +DB_DATABASE=standard_notes_db +DB_DEBUG_LEVEL=all # "all" | "query" | "schema" | "error" | "warn" | "info" | "log" | "migration" +DB_MIGRATIONS_PATH=dist/migrations/*.js + +REDIS_URL=redis://cache + +SQS_QUEUE_URL= +SQS_AWS_REGION= +S3_AWS_REGION= +S3_BACKUP_BUCKET_NAME= + +REDIS_EVENTS_CHANNEL=revisions + +# (Optional) New Relic Setup +NEW_RELIC_ENABLED=false +NEW_RELIC_APP_NAME="Revisions Server" +NEW_RELIC_LICENSE_KEY= +NEW_RELIC_NO_CONFIG_FILE=true +NEW_RELIC_DISTRIBUTED_TRACING_ENABLED=false +NEW_RELIC_LOG_ENABLED=false +NEW_RELIC_LOG_LEVEL=info diff --git a/packages/revisions/.eslintignore b/packages/revisions/.eslintignore new file mode 100644 index 000000000..4186e3d19 --- /dev/null +++ b/packages/revisions/.eslintignore @@ -0,0 +1,3 @@ +dist +test-setup.ts +data diff --git a/packages/revisions/.eslintrc b/packages/revisions/.eslintrc new file mode 100644 index 000000000..cb7136174 --- /dev/null +++ b/packages/revisions/.eslintrc @@ -0,0 +1,6 @@ +{ + "extends": "../../.eslintrc", + "parserOptions": { + "project": "./linter.tsconfig.json" + } +} diff --git a/packages/revisions/Dockerfile b/packages/revisions/Dockerfile new file mode 100644 index 000000000..5b89531f6 --- /dev/null +++ b/packages/revisions/Dockerfile @@ -0,0 +1,17 @@ +FROM node:18.12.1-alpine + +RUN apk add --update \ + curl \ + && rm -rf /var/cache/apk/* + +ENV NODE_ENV production + +RUN corepack enable + +WORKDIR /workspace + +COPY ./ /workspace + +ENTRYPOINT [ "/workspace/packages/syncing-server/docker/entrypoint.sh" ] + +CMD [ "start-web" ] diff --git a/packages/revisions/bin/server.ts b/packages/revisions/bin/server.ts new file mode 100644 index 000000000..2badf9638 --- /dev/null +++ b/packages/revisions/bin/server.ts @@ -0,0 +1,70 @@ +import 'reflect-metadata' + +import 'newrelic' + +import * as Sentry from '@sentry/node' + +import '../src/Infra/InversifyExpress/InversifyExpressRevisionsController' +import '../src/Infra/InversifyExpress/InversifyExpressHealthCheckController' + +import * as cors from 'cors' +import { urlencoded, json, Request, Response, NextFunction, RequestHandler, ErrorRequestHandler } from 'express' +import * as winston from 'winston' + +import { InversifyExpressServer } from 'inversify-express-utils' +import { ContainerConfigLoader } from '../src/Bootstrap/Container' +import TYPES from '../src/Bootstrap/Types' +import { Env } from '../src/Bootstrap/Env' + +const container = new ContainerConfigLoader() +void container.load().then((container) => { + const env: Env = new Env() + env.load() + + const server = new InversifyExpressServer(container) + + server.setConfig((app) => { + app.use((_request: Request, response: Response, next: NextFunction) => { + response.setHeader('X-Revisions-Version', container.get(TYPES.VERSION)) + next() + }) + app.use(json()) + app.use(urlencoded({ extended: true })) + app.use(cors()) + + if (env.get('SENTRY_DSN', true)) { + Sentry.init({ + dsn: env.get('SENTRY_DSN'), + integrations: [new Sentry.Integrations.Http({ tracing: false, breadcrumbs: true })], + tracesSampleRate: 0, + }) + + app.use(Sentry.Handlers.requestHandler() as RequestHandler) + } + }) + + const logger: winston.Logger = container.get(TYPES.Logger) + + server.setErrorConfig((app) => { + if (env.get('SENTRY_DSN', true)) { + app.use(Sentry.Handlers.errorHandler() as ErrorRequestHandler) + } + + app.use((error: Record, _request: Request, response: Response, _next: NextFunction) => { + logger.error(error.stack) + + response.status(500).send({ + error: { + message: + "Unfortunately, we couldn't handle your request. Please try again or contact our support if the error persists.", + }, + }) + }) + }) + + const serverInstance = server.build() + + serverInstance.listen(env.get('PORT')) + + logger.info(`Server started on port ${process.env.PORT}`) +}) diff --git a/packages/revisions/docker/entrypoint.sh b/packages/revisions/docker/entrypoint.sh new file mode 100755 index 000000000..dd2353605 --- /dev/null +++ b/packages/revisions/docker/entrypoint.sh @@ -0,0 +1,22 @@ +#!/bin/sh +set -e + +COMMAND=$1 && shift 1 + +case "$COMMAND" in + 'start-web' ) + echo "Starting Web..." + yarn workspace @standardnotes/revisions-server start + ;; + + 'start-worker' ) + echo "Starting Worker..." + yarn workspace @standardnotes/revisions-server worker + ;; + + * ) + echo "Unknown command" + ;; +esac + +exec "$@" diff --git a/packages/revisions/jest.config.js b/packages/revisions/jest.config.js new file mode 100644 index 000000000..db727ebce --- /dev/null +++ b/packages/revisions/jest.config.js @@ -0,0 +1,11 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const base = require('../../jest.config') +const { defaults: tsjPreset } = require('ts-jest/presets') + +module.exports = { + ...base, + transform: { + ...tsjPreset.transform, + }, + coveragePathIgnorePatterns: ['/Bootstrap/', 'HealthCheckController', '/Infra/', '/Mapping/'], +} diff --git a/packages/revisions/linter.tsconfig.json b/packages/revisions/linter.tsconfig.json new file mode 100644 index 000000000..67d92b038 --- /dev/null +++ b/packages/revisions/linter.tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["dist", "test-setup.ts"] +} diff --git a/packages/revisions/package.json b/packages/revisions/package.json new file mode 100644 index 000000000..906ec8d03 --- /dev/null +++ b/packages/revisions/package.json @@ -0,0 +1,66 @@ +{ + "name": "@standardnotes/revisions-server", + "version": "1.0.0", + "engines": { + "node": ">=18.0.0 <19.0.0" + }, + "private": true, + "description": "Revisions Server", + "main": "dist/src/index.js", + "typings": "dist/src/index.d.ts", + "repository": "git@github.com:standardnotes/syncing-server-js.git", + "author": "Karol Sójko ", + "license": "AGPL-3.0-or-later", + "scripts": { + "clean": "rm -fr dist", + "setup:env": "cp .env.sample .env", + "build": "tsc --build", + "lint": "eslint . --ext .ts", + "lint:fix": "eslint . --ext .ts --fix", + "pretest": "yarn lint && yarn build", + "test": "jest --coverage --config=./jest.config.js --maxWorkers=50%", + "start": "yarn node dist/bin/server.js", + "worker": "yarn node dist/bin/worker.js" + }, + "dependencies": { + "@newrelic/native-metrics": "^9.0.0", + "@newrelic/winston-enricher": "^4.0.0", + "@sentry/node": "^7.19.0", + "@standardnotes/api": "^1.19.0", + "@standardnotes/common": "workspace:^", + "@standardnotes/domain-core": "workspace:^", + "@standardnotes/domain-events": "workspace:*", + "@standardnotes/domain-events-infra": "workspace:*", + "@standardnotes/security": "workspace:^", + "@standardnotes/time": "workspace:^", + "aws-sdk": "^2.1253.0", + "cors": "2.8.5", + "dotenv": "^16.0.1", + "express": "^4.18.2", + "helmet": "^6.0.0", + "inversify": "^6.0.1", + "inversify-express-utils": "^6.4.3", + "ioredis": "^5.2.4", + "mysql2": "^2.3.3", + "newrelic": "^9.6.0", + "reflect-metadata": "0.1.13", + "typeorm": "^0.3.10", + "winston": "^3.8.1" + }, + "devDependencies": { + "@types/cors": "^2.8.9", + "@types/dotenv": "^8.2.0", + "@types/express": "^4.17.14", + "@types/inversify-express-utils": "^2.0.0", + "@types/ioredis": "^5.0.0", + "@types/jest": "^29.1.1", + "@types/newrelic": "^7.0.4", + "@typescript-eslint/eslint-plugin": "^5.29.0", + "eslint": "^8.14.0", + "eslint-plugin-prettier": "^4.0.0", + "jest": "^29.1.2", + "npm-check-updates": "^16.0.1", + "ts-jest": "^29.0.3", + "typescript": "^4.8.4" + } +} diff --git a/packages/revisions/src/Bootstrap/Container.ts b/packages/revisions/src/Bootstrap/Container.ts new file mode 100644 index 000000000..79219b6b7 --- /dev/null +++ b/packages/revisions/src/Bootstrap/Container.ts @@ -0,0 +1,174 @@ +import * as winston from 'winston' +import Redis from 'ioredis' +import * as AWS from 'aws-sdk' +import { Container } from 'inversify' +import { + DomainEventHandlerInterface, + DomainEventMessageHandlerInterface, + DomainEventSubscriberFactoryInterface, +} from '@standardnotes/domain-events' +import { TokenDecoderInterface, CrossServiceTokenData, TokenDecoder } from '@standardnotes/security' +import { + RedisDomainEventSubscriberFactory, + RedisEventMessageHandler, + SQSDomainEventSubscriberFactory, + SQSEventMessageHandler, + SQSNewRelicEventMessageHandler, +} from '@standardnotes/domain-events-infra' + +import { Env } from './Env' +import TYPES from './Types' +import { AppDataSource } from './DataSource' +import { InversifyExpressApiGatewayAuthMiddleware } from '../Infra/InversifyExpress/InversifyExpressApiGatewayAuthMiddleware' +import { RevisionsController } from '../Controller/RevisionsController' +import { GetRevisionsMetada } from '../Domain/UseCase/GetRevisionsMetada/GetRevisionsMetada' +import { RevisionRepositoryInterface } from '../Domain/Revision/RevisionRepositoryInterface' +import { MySQLRevisionRepository } from '../Infra/MySQL/MySQLRevisionRepository' +import { RevisionMetadataPersistenceMapper } from '../Mapping/RevisionMetadataPersistenceMapper' +import { MapperInterface, RevisionMetadata } from '@standardnotes/domain-core' +import { TypeORMRevision } from '../Infra/TypeORM/TypeORMRevision' +import { Repository } from 'typeorm' + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const newrelicFormatter = require('@newrelic/winston-enricher') + +export class ContainerConfigLoader { + async load(): Promise { + const env: Env = new Env() + env.load() + + const container = new Container() + + await AppDataSource.initialize() + + const redisUrl = env.get('REDIS_URL') + const isRedisInClusterMode = redisUrl.indexOf(',') > 0 + let redis + if (isRedisInClusterMode) { + redis = new Redis.Cluster(redisUrl.split(',')) + } else { + redis = new Redis(redisUrl) + } + + container.bind(TYPES.Redis).toConstantValue(redis) + + const newrelicWinstonFormatter = newrelicFormatter(winston) + const winstonFormatters = [winston.format.splat(), winston.format.json()] + if (env.get('NEW_RELIC_ENABLED', true) === 'true') { + winstonFormatters.push(newrelicWinstonFormatter()) + } + + const logger = winston.createLogger({ + level: env.get('LOG_LEVEL') || 'info', + format: winston.format.combine(...winstonFormatters), + transports: [new winston.transports.Console({ level: env.get('LOG_LEVEL') || 'info' })], + }) + container.bind(TYPES.Logger).toConstantValue(logger) + + if (env.get('SQS_AWS_REGION', true)) { + container.bind(TYPES.SQS).toConstantValue( + new AWS.SQS({ + apiVersion: 'latest', + region: env.get('SQS_AWS_REGION', true), + }), + ) + } + + let s3Client = undefined + if (env.get('S3_AWS_REGION', true)) { + s3Client = new AWS.S3({ + apiVersion: 'latest', + region: env.get('S3_AWS_REGION', true), + }) + } + container.bind(TYPES.S3).toConstantValue(s3Client) + + // Map + container + .bind>(TYPES.RevisionMetadataPersistenceMapper) + .toConstantValue(new RevisionMetadataPersistenceMapper()) + + // ORM + container + .bind>(TYPES.ORMRevisionRepository) + .toConstantValue(AppDataSource.getRepository(TypeORMRevision)) + + // Repositories + container + .bind(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)) + container.bind(TYPES.REDIS_EVENTS_CHANNEL).toConstantValue(env.get('REDIS_EVENTS_CHANNEL')) + container.bind(TYPES.AUTH_JWT_SECRET).toConstantValue(env.get('AUTH_JWT_SECRET')) + container.bind(TYPES.S3_AWS_REGION).toConstantValue(env.get('S3_AWS_REGION', true)) + container.bind(TYPES.S3_BACKUP_BUCKET_NAME).toConstantValue(env.get('S3_BACKUP_BUCKET_NAME', true)) + container.bind(TYPES.NEW_RELIC_ENABLED).toConstantValue(env.get('NEW_RELIC_ENABLED', true)) + container.bind(TYPES.VERSION).toConstantValue(env.get('VERSION')) + + // use cases + container + .bind(TYPES.GetRevisionsMetada) + .toConstantValue(new GetRevisionsMetada(container.get(TYPES.RevisionRepository))) + + // Controller + container + .bind(TYPES.RevisionsController) + .toConstantValue(new RevisionsController(container.get(TYPES.GetRevisionsMetada), container.get(TYPES.Logger))) + + // Handlers + + // Services + container + .bind>(TYPES.CrossServiceTokenDecoder) + .toConstantValue(new TokenDecoder(container.get(TYPES.AUTH_JWT_SECRET))) + + // Middleware + container + .bind(TYPES.ApiGatewayAuthMiddleware) + .to(InversifyExpressApiGatewayAuthMiddleware) + + const eventHandlers: Map = new Map([]) + + if (env.get('SQS_QUEUE_URL', true)) { + container + .bind(TYPES.DomainEventMessageHandler) + .toConstantValue( + env.get('NEW_RELIC_ENABLED', true) === 'true' + ? new SQSNewRelicEventMessageHandler(eventHandlers, container.get(TYPES.Logger)) + : new SQSEventMessageHandler(eventHandlers, container.get(TYPES.Logger)), + ) + container + .bind(TYPES.DomainEventSubscriberFactory) + .toConstantValue( + new SQSDomainEventSubscriberFactory( + container.get(TYPES.SQS), + container.get(TYPES.SQS_QUEUE_URL), + container.get(TYPES.DomainEventMessageHandler), + ), + ) + } else { + container + .bind(TYPES.DomainEventMessageHandler) + .toConstantValue(new RedisEventMessageHandler(eventHandlers, container.get(TYPES.Logger))) + container + .bind(TYPES.DomainEventSubscriberFactory) + .toConstantValue( + new RedisDomainEventSubscriberFactory( + container.get(TYPES.Redis), + container.get(TYPES.DomainEventMessageHandler), + container.get(TYPES.REDIS_EVENTS_CHANNEL), + ), + ) + } + + return container + } +} diff --git a/packages/revisions/src/Bootstrap/DataSource.ts b/packages/revisions/src/Bootstrap/DataSource.ts new file mode 100644 index 000000000..028cf8c95 --- /dev/null +++ b/packages/revisions/src/Bootstrap/DataSource.ts @@ -0,0 +1,44 @@ +import { DataSource, LoggerOptions } from 'typeorm' + +import { TypeORMRevision } from '../Infra/TypeORM/TypeORMRevision' + +import { Env } from './Env' + +const env: Env = new Env() +env.load() + +const maxQueryExecutionTime = env.get('DB_MAX_QUERY_EXECUTION_TIME', true) + ? +env.get('DB_MAX_QUERY_EXECUTION_TIME', true) + : 45_000 + +export const AppDataSource = new DataSource({ + type: 'mysql', + charset: 'utf8mb4', + supportBigNumbers: true, + bigNumberStrings: false, + maxQueryExecutionTime, + replication: { + master: { + host: env.get('DB_HOST'), + port: parseInt(env.get('DB_PORT')), + username: env.get('DB_USERNAME'), + password: env.get('DB_PASSWORD'), + database: env.get('DB_DATABASE'), + }, + slaves: [ + { + host: env.get('DB_REPLICA_HOST'), + port: parseInt(env.get('DB_PORT')), + username: env.get('DB_USERNAME'), + password: env.get('DB_PASSWORD'), + database: env.get('DB_DATABASE'), + }, + ], + removeNodeErrorCount: 10, + restoreNodeTimeout: 5, + }, + entities: [TypeORMRevision], + migrations: [env.get('DB_MIGRATIONS_PATH', true) ?? 'dist/migrations/*.js'], + migrationsRun: true, + logging: env.get('DB_DEBUG_LEVEL'), +}) diff --git a/packages/revisions/src/Bootstrap/Env.ts b/packages/revisions/src/Bootstrap/Env.ts new file mode 100644 index 000000000..b26b07aca --- /dev/null +++ b/packages/revisions/src/Bootstrap/Env.ts @@ -0,0 +1,24 @@ +import { config, DotenvParseOutput } from 'dotenv' +import { injectable } from 'inversify' + +@injectable() +export class Env { + private env?: DotenvParseOutput + + public load(): void { + const output = config() + this.env = output.parsed + } + + public get(key: string, optional = false): string { + if (!this.env) { + this.load() + } + + if (!process.env[key] && !optional) { + throw new Error(`Environment variable ${key} not set`) + } + + return process.env[key] + } +} diff --git a/packages/revisions/src/Bootstrap/Types.ts b/packages/revisions/src/Bootstrap/Types.ts new file mode 100644 index 000000000..fbcc06388 --- /dev/null +++ b/packages/revisions/src/Bootstrap/Types.ts @@ -0,0 +1,37 @@ +const TYPES = { + DBConnection: Symbol.for('DBConnection'), + Logger: Symbol.for('Logger'), + Redis: Symbol.for('Redis'), + SQS: Symbol.for('SQS'), + S3: Symbol.for('S3'), + // Map + RevisionMetadataPersistenceMapper: Symbol.for('RevisionMetadataPersistenceMapper'), + // ORM + ORMRevisionRepository: Symbol.for('ORMRevisionRepository'), + // Repositories + RevisionRepository: Symbol.for('RevisionRepository'), + // env vars + REDIS_URL: Symbol.for('REDIS_URL'), + SQS_QUEUE_URL: Symbol.for('SQS_QUEUE_URL'), + SQS_AWS_REGION: Symbol.for('SQS_AWS_REGION'), + REDIS_EVENTS_CHANNEL: Symbol.for('REDIS_EVENTS_CHANNEL'), + AUTH_JWT_SECRET: Symbol.for('AUTH_JWT_SECRET'), + S3_AWS_REGION: Symbol.for('S3_AWS_REGION'), + S3_BACKUP_BUCKET_NAME: Symbol.for('S3_BACKUP_BUCKET_NAME'), + NEW_RELIC_ENABLED: Symbol.for('NEW_RELIC_ENABLED'), + VERSION: Symbol.for('VERSION'), + // use cases + GetRevisionsMetada: Symbol.for('GetRevisionsMetada'), + // Controller + RevisionsController: Symbol.for('RevisionsController'), + // Handlers + // Services + CrossServiceTokenDecoder: Symbol.for('CrossServiceTokenDecoder'), + DomainEventSubscriberFactory: Symbol.for('DomainEventSubscriberFactory'), + DomainEventMessageHandler: Symbol.for('DomainEventMessageHandler'), + Timer: Symbol.for('Timer'), + // Middleware + ApiGatewayAuthMiddleware: Symbol.for('ApiGatewayAuthMiddleware'), +} + +export default TYPES diff --git a/packages/revisions/src/Controller/RevisionsController.spec.ts b/packages/revisions/src/Controller/RevisionsController.spec.ts new file mode 100644 index 000000000..5de511e9a --- /dev/null +++ b/packages/revisions/src/Controller/RevisionsController.spec.ts @@ -0,0 +1,34 @@ +import { Result } from '@standardnotes/domain-core' +import { Logger } from 'winston' + +import { GetRevisionsMetada } from '../Domain/UseCase/GetRevisionsMetada/GetRevisionsMetada' + +import { RevisionsController } from './RevisionsController' + +describe('RevisionsController', () => { + let getRevisionsMetadata: GetRevisionsMetada + let logger: Logger + + const createController = () => new RevisionsController(getRevisionsMetadata, logger) + + beforeEach(() => { + getRevisionsMetadata = {} as jest.Mocked + getRevisionsMetadata.execute = jest.fn().mockReturnValue(Result.ok()) + + logger = {} as jest.Mocked + logger.warn = jest.fn() + }) + + it('should get revisions list', async () => { + const response = await createController().getRevisions({ itemUuid: '1-2-3' }) + + expect(response.status).toEqual(200) + }) + + it('should indicate failure to get revisions list', async () => { + getRevisionsMetadata.execute = jest.fn().mockReturnValue(Result.fail('Oops')) + const response = await createController().getRevisions({ itemUuid: '1-2-3' }) + + expect(response.status).toEqual(400) + }) +}) diff --git a/packages/revisions/src/Controller/RevisionsController.ts b/packages/revisions/src/Controller/RevisionsController.ts new file mode 100644 index 000000000..31aaef5bd --- /dev/null +++ b/packages/revisions/src/Controller/RevisionsController.ts @@ -0,0 +1,31 @@ +import { Logger } from 'winston' +import { HttpResponse, HttpStatusCode } from '@standardnotes/api' + +import { GetRevisionsMetada } from '../Domain/UseCase/GetRevisionsMetada/GetRevisionsMetada' +import { GetRevisionsMetadataRequestParams } from '../Infra/Http/GetRevisionsMetadataRequestParams' + +export class RevisionsController { + constructor(private getRevisionsMetadata: GetRevisionsMetada, private logger: Logger) {} + + async getRevisions(params: GetRevisionsMetadataRequestParams): Promise { + const revisionMetadataOrError = await this.getRevisionsMetadata.execute({ itemUuid: params.itemUuid }) + + if (revisionMetadataOrError.isFailed()) { + this.logger.warn(revisionMetadataOrError.getError()) + + return { + status: HttpStatusCode.BadRequest, + data: { + error: { + message: 'Could not retrieve revisions.', + }, + }, + } + } + + return { + status: HttpStatusCode.Success, + data: { revisions: revisionMetadataOrError.getValue() }, + } + } +} diff --git a/packages/revisions/src/Domain/Revision/RevisionRepositoryInterface.ts b/packages/revisions/src/Domain/Revision/RevisionRepositoryInterface.ts new file mode 100644 index 000000000..be09ad38b --- /dev/null +++ b/packages/revisions/src/Domain/Revision/RevisionRepositoryInterface.ts @@ -0,0 +1,5 @@ +import { Uuid, RevisionMetadata } from '@standardnotes/domain-core' + +export interface RevisionRepositoryInterface { + findMetadataByItemId(itemUuid: Uuid): Promise> +} diff --git a/packages/revisions/src/Domain/UseCase/GetRevisionsMetada/GetRevisionsMetada.spec.ts b/packages/revisions/src/Domain/UseCase/GetRevisionsMetada/GetRevisionsMetada.spec.ts new file mode 100644 index 000000000..a9e7f5606 --- /dev/null +++ b/packages/revisions/src/Domain/UseCase/GetRevisionsMetada/GetRevisionsMetada.spec.ts @@ -0,0 +1,28 @@ +import { RevisionMetadata } from '@standardnotes/domain-core' + +import { RevisionRepositoryInterface } from '../../Revision/RevisionRepositoryInterface' +import { GetRevisionsMetada } from './GetRevisionsMetada' + +describe('GetRevisionsMetada', () => { + let revisionRepository: RevisionRepositoryInterface + + const createUseCase = () => new GetRevisionsMetada(revisionRepository) + + beforeEach(() => { + revisionRepository = {} as jest.Mocked + revisionRepository.findMetadataByItemId = jest.fn().mockReturnValue([{} as jest.Mocked]) + }) + + it('should return revisions metadata for a given item', async () => { + const result = await createUseCase().execute({ itemUuid: '84c0f8e8-544a-4c7e-9adf-26209303bc1d' }) + + expect(result.isFailed()).toBeFalsy() + expect(result.getValue().length).toEqual(1) + }) + + it('should not return revisions metadata for a an invalid item uuid', async () => { + const result = await createUseCase().execute({ itemUuid: '1-2-3' }) + + expect(result.isFailed()).toBeTruthy() + }) +}) diff --git a/packages/revisions/src/Domain/UseCase/GetRevisionsMetada/GetRevisionsMetada.ts b/packages/revisions/src/Domain/UseCase/GetRevisionsMetada/GetRevisionsMetada.ts new file mode 100644 index 000000000..3e588c492 --- /dev/null +++ b/packages/revisions/src/Domain/UseCase/GetRevisionsMetada/GetRevisionsMetada.ts @@ -0,0 +1,20 @@ +import { Result, RevisionMetadata, UseCaseInterface, Uuid } from '@standardnotes/domain-core' + +import { RevisionRepositoryInterface } from '../../Revision/RevisionRepositoryInterface' + +import { GetRevisionsMetadaDTO } from './GetRevisionsMetadaDTO' + +export class GetRevisionsMetada implements UseCaseInterface { + constructor(private revisionRepository: RevisionRepositoryInterface) {} + + async execute(dto: GetRevisionsMetadaDTO): Promise> { + const itemUuidOrError = Uuid.create(dto.itemUuid) + if (itemUuidOrError.isFailed()) { + return Result.fail(`Could not get revisions: ${itemUuidOrError.getError()}`) + } + + const revisionsMetdata = await this.revisionRepository.findMetadataByItemId(itemUuidOrError.getValue()) + + return Result.ok(revisionsMetdata) + } +} diff --git a/packages/revisions/src/Domain/UseCase/GetRevisionsMetada/GetRevisionsMetadaDTO.ts b/packages/revisions/src/Domain/UseCase/GetRevisionsMetada/GetRevisionsMetadaDTO.ts new file mode 100644 index 000000000..dd86c1de0 --- /dev/null +++ b/packages/revisions/src/Domain/UseCase/GetRevisionsMetada/GetRevisionsMetadaDTO.ts @@ -0,0 +1,3 @@ +export interface GetRevisionsMetadaDTO { + itemUuid: string +} diff --git a/packages/revisions/src/Infra/Http/GetRevisionsMetadataRequestParams.ts b/packages/revisions/src/Infra/Http/GetRevisionsMetadataRequestParams.ts new file mode 100644 index 000000000..084292056 --- /dev/null +++ b/packages/revisions/src/Infra/Http/GetRevisionsMetadataRequestParams.ts @@ -0,0 +1,3 @@ +export interface GetRevisionsMetadataRequestParams { + itemUuid: string +} diff --git a/packages/revisions/src/Infra/InversifyExpress/InversifyExpressApiGatewayAuthMiddleware.ts b/packages/revisions/src/Infra/InversifyExpress/InversifyExpressApiGatewayAuthMiddleware.ts new file mode 100644 index 000000000..069dcd891 --- /dev/null +++ b/packages/revisions/src/Infra/InversifyExpress/InversifyExpressApiGatewayAuthMiddleware.ts @@ -0,0 +1,60 @@ +import { CrossServiceTokenData, TokenDecoderInterface } from '@standardnotes/security' +import { NextFunction, Request, Response } from 'express' +import { inject, injectable } from 'inversify' +import { BaseMiddleware } from 'inversify-express-utils' +import { Logger } from 'winston' + +import TYPES from '../../Bootstrap/Types' + +@injectable() +export class InversifyExpressApiGatewayAuthMiddleware extends BaseMiddleware { + constructor( + @inject(TYPES.CrossServiceTokenDecoder) private tokenDecoder: TokenDecoderInterface, + @inject(TYPES.Logger) private logger: Logger, + ) { + super() + } + + async handler(request: Request, response: Response, next: NextFunction): Promise { + try { + if (!request.headers['x-auth-token']) { + this.logger.debug('ApiGatewayAuthMiddleware missing x-auth-token header.') + + response.status(401).send({ + error: { + tag: 'invalid-auth', + message: 'Invalid login credentials.', + }, + }) + + return + } + + const token: CrossServiceTokenData | undefined = this.tokenDecoder.decodeToken( + request.headers['x-auth-token'] as string, + ) + + if (token === undefined) { + this.logger.debug('ApiGatewayAuthMiddleware authentication failure.') + + response.status(401).send({ + error: { + tag: 'invalid-auth', + message: 'Invalid login credentials.', + }, + }) + + return + } + + response.locals.user = token.user + response.locals.roles = token.roles + response.locals.session = token.session + response.locals.readOnlyAccess = token.session?.readonly_access ?? false + + return next() + } catch (error) { + return next(error) + } + } +} diff --git a/packages/revisions/src/Infra/InversifyExpress/InversifyExpressHealthCheckController.ts b/packages/revisions/src/Infra/InversifyExpress/InversifyExpressHealthCheckController.ts new file mode 100644 index 000000000..535288409 --- /dev/null +++ b/packages/revisions/src/Infra/InversifyExpress/InversifyExpressHealthCheckController.ts @@ -0,0 +1,9 @@ +import { controller, httpGet } from 'inversify-express-utils' + +@controller('/healthcheck') +export class InversifyExpressHealthCheckController { + @httpGet('/') + public async get(): Promise { + return 'OK' + } +} diff --git a/packages/revisions/src/Infra/InversifyExpress/InversifyExpressRevisionsController.ts b/packages/revisions/src/Infra/InversifyExpress/InversifyExpressRevisionsController.ts new file mode 100644 index 000000000..ebebc3b0c --- /dev/null +++ b/packages/revisions/src/Infra/InversifyExpress/InversifyExpressRevisionsController.ts @@ -0,0 +1,22 @@ +import { Request } from 'express' +import { BaseHttpController, controller, httpGet, results } from 'inversify-express-utils' +import { inject } from 'inversify' + +import TYPES from '../../Bootstrap/Types' +import { RevisionsController } from '../../Controller/RevisionsController' + +@controller('/items/:itemUuid/revisions', TYPES.ApiGatewayAuthMiddleware) +export class InversifyExpressRevisionsController extends BaseHttpController { + constructor(@inject(TYPES.RevisionsController) private revisionsController: RevisionsController) { + super() + } + + @httpGet('/') + public async getRevisions(req: Request): Promise { + const result = await this.revisionsController.getRevisions({ + itemUuid: req.params.itemUuid, + }) + + return this.json(result.data, result.status) + } +} diff --git a/packages/revisions/src/Infra/MySQL/MySQLRevisionRepository.ts b/packages/revisions/src/Infra/MySQL/MySQLRevisionRepository.ts new file mode 100644 index 000000000..624a9a863 --- /dev/null +++ b/packages/revisions/src/Infra/MySQL/MySQLRevisionRepository.ts @@ -0,0 +1,34 @@ +import { MapperInterface, RevisionMetadata, Uuid } from '@standardnotes/domain-core' +import { Repository } from 'typeorm' + +import { RevisionRepositoryInterface } from '../../Domain/Revision/RevisionRepositoryInterface' +import { TypeORMRevision } from '../TypeORM/TypeORMRevision' + +export class MySQLRevisionRepository implements RevisionRepositoryInterface { + constructor( + private ormRepository: Repository, + private revisionMapper: MapperInterface, + ) {} + + async findMetadataByItemId(itemUuid: Uuid): Promise> { + const queryBuilder = this.ormRepository + .createQueryBuilder() + .select('uuid', 'uuid') + .addSelect('content_type', 'contentType') + .addSelect('created_at', 'createdAt') + .addSelect('updated_at', 'updatedAt') + .where('item_uuid = :item_uuid', { + item_uuid: itemUuid, + }) + .orderBy('created_at', 'DESC') + + const simplifiedRevisions = await queryBuilder.getMany() + + const metadata = [] + for (const simplifiedRevision of simplifiedRevisions) { + metadata.push(this.revisionMapper.toDomain(simplifiedRevision)) + } + + return metadata + } +} diff --git a/packages/revisions/src/Infra/TypeORM/TypeORMRevision.ts b/packages/revisions/src/Infra/TypeORM/TypeORMRevision.ts new file mode 100644 index 000000000..b5711af43 --- /dev/null +++ b/packages/revisions/src/Infra/TypeORM/TypeORMRevision.ts @@ -0,0 +1,77 @@ +import { ContentType } from '@standardnotes/common' + +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm' + +@Entity({ name: 'revisions' }) +export class TypeORMRevision { + @PrimaryGeneratedColumn('uuid') + declare uuid: string + + @Column({ + name: 'item_uuid', + length: 36, + }) + declare itemUuid: string + + @Column({ + type: 'mediumtext', + nullable: true, + }) + declare content: string | null + + @Column({ + name: 'content_type', + type: 'varchar', + length: 255, + nullable: true, + }) + declare contentType: ContentType | null + + @Column({ + type: 'varchar', + name: 'items_key_id', + length: 255, + nullable: true, + }) + declare itemsKeyId: string | null + + @Column({ + name: 'enc_item_key', + type: 'text', + nullable: true, + }) + declare encItemKey: string | null + + @Column({ + name: 'auth_hash', + type: 'varchar', + length: 255, + nullable: true, + }) + declare authHash: string | null + + @Column({ + name: 'creation_date', + type: 'date', + nullable: true, + }) + @Index('index_revisions_on_creation_date') + declare creationDate: Date + + @Column({ + name: 'created_at', + type: 'datetime', + precision: 6, + nullable: true, + }) + @Index('index_revisions_on_created_at') + declare createdAt: Date + + @Column({ + name: 'updated_at', + type: 'datetime', + precision: 6, + nullable: true, + }) + declare updatedAt: Date +} diff --git a/packages/revisions/src/Mapping/RevisionMetadataPersistenceMapper.ts b/packages/revisions/src/Mapping/RevisionMetadataPersistenceMapper.ts new file mode 100644 index 000000000..a28273e8d --- /dev/null +++ b/packages/revisions/src/Mapping/RevisionMetadataPersistenceMapper.ts @@ -0,0 +1,37 @@ +import { RevisionMetadata, MapperInterface, UniqueEntityId, ContentType, Timestamps } from '@standardnotes/domain-core' + +import { TypeORMRevision } from '../Infra/TypeORM/TypeORMRevision' + +export class RevisionMetadataPersistenceMapper implements MapperInterface { + toDomain(projection: TypeORMRevision): RevisionMetadata { + const contentTypeOrError = ContentType.create(projection.contentType) + if (contentTypeOrError.isFailed()) { + throw new Error(`Could not create content type: ${contentTypeOrError.getError()}`) + } + const contentType = contentTypeOrError.getValue() + + const timestampsOrError = Timestamps.create(projection.createdAt, projection.updatedAt) + if (timestampsOrError.isFailed()) { + throw new Error(`Could not create timestamps: ${timestampsOrError.getError()}`) + } + const timestamps = timestampsOrError.getValue() + + const revisionMetadataOrError = RevisionMetadata.create( + { + contentType, + timestamps, + }, + new UniqueEntityId(projection.uuid), + ) + + if (revisionMetadataOrError.isFailed()) { + throw new Error(`Could not create revision metdata: ${revisionMetadataOrError.getError()}`) + } + + return revisionMetadataOrError.getValue() + } + + toProjection(_domain: RevisionMetadata): TypeORMRevision { + throw new Error('Method not implemented.') + } +} diff --git a/packages/revisions/tsconfig.json b/packages/revisions/tsconfig.json new file mode 100644 index 000000000..d87b89eeb --- /dev/null +++ b/packages/revisions/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + }, + "include": [ + "src/**/*", + "bin/**/*", + "migrations/**/*", + ], + "references": [] +} diff --git a/packages/revisions/wait-for.sh b/packages/revisions/wait-for.sh new file mode 100755 index 000000000..f3d72b834 --- /dev/null +++ b/packages/revisions/wait-for.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +set -e + +host="$1" +shift +port="$1" +shift +cmd="$@" + +while ! nc -vz $host $port; do + >&2 echo "$host:$port is unavailable yet - waiting for it to start" + sleep 10 +done + +>&2 echo "$host:$port is up - executing command" +exec $cmd diff --git a/tsconfig.json b/tsconfig.json index 5ae203450..fa23539dd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -53,6 +53,9 @@ { "path": "./packages/predicates" }, + { + "path": "./packages/revisions" + }, { "path": "./packages/scheduler" }, diff --git a/yarn.lock b/yarn.lock index e9d653f5f..36c22b1ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2239,6 +2239,50 @@ __metadata: languageName: node linkType: hard +"@standardnotes/revisions-server@workspace:packages/revisions": + version: 0.0.0-use.local + resolution: "@standardnotes/revisions-server@workspace:packages/revisions" + dependencies: + "@newrelic/native-metrics": "npm:^9.0.0" + "@newrelic/winston-enricher": "npm:^4.0.0" + "@sentry/node": "npm:^7.19.0" + "@standardnotes/api": "npm:^1.19.0" + "@standardnotes/common": "workspace:^" + "@standardnotes/domain-core": "workspace:^" + "@standardnotes/domain-events": "workspace:*" + "@standardnotes/domain-events-infra": "workspace:*" + "@standardnotes/security": "workspace:^" + "@standardnotes/time": "workspace:^" + "@types/cors": "npm:^2.8.9" + "@types/dotenv": "npm:^8.2.0" + "@types/express": "npm:^4.17.14" + "@types/inversify-express-utils": "npm:^2.0.0" + "@types/ioredis": "npm:^5.0.0" + "@types/jest": "npm:^29.1.1" + "@types/newrelic": "npm:^7.0.4" + "@typescript-eslint/eslint-plugin": "npm:^5.29.0" + aws-sdk: "npm:^2.1253.0" + cors: "npm:2.8.5" + dotenv: "npm:^16.0.1" + eslint: "npm:^8.14.0" + eslint-plugin-prettier: "npm:^4.0.0" + express: "npm:^4.18.2" + helmet: "npm:^6.0.0" + inversify: "npm:^6.0.1" + inversify-express-utils: "npm:^6.4.3" + ioredis: "npm:^5.2.4" + jest: "npm:^29.1.2" + mysql2: "npm:^2.3.3" + newrelic: "npm:^9.6.0" + npm-check-updates: "npm:^16.0.1" + reflect-metadata: "npm:0.1.13" + ts-jest: "npm:^29.0.3" + typeorm: "npm:^0.3.10" + typescript: "npm:^4.8.4" + winston: "npm:^3.8.1" + languageName: unknown + linkType: soft + "@standardnotes/scheduler-server@workspace:packages/scheduler": version: 0.0.0-use.local resolution: "@standardnotes/scheduler-server@workspace:packages/scheduler"