소스 검색

feat(revisions): add revisions microservice

Karol Sójko 2 년 전
부모
커밋
d5c06bfa58
44개의 변경된 파일1156개의 추가작업 그리고 10개의 파일을 삭제
  1. 46 0
      .github/workflows/revisions.yml
  2. 51 0
      .pnp.cjs
  3. 2 0
      package.json
  4. 21 0
      packages/domain-core/src/Domain/Common/Timestamps.spec.ts
  5. 32 0
      packages/domain-core/src/Domain/Common/Timestamps.ts
  6. 4 0
      packages/domain-core/src/Domain/Common/TimestampsProps.ts
  7. 3 3
      packages/domain-core/src/Domain/Common/Uuid.spec.ts
  8. 2 2
      packages/domain-core/src/Domain/Core/Validator.spec.ts
  9. 0 4
      packages/domain-core/src/Domain/Map/MapInterface.ts
  10. 4 0
      packages/domain-core/src/Domain/Mapping/MapperInterface.ts
  11. 19 0
      packages/domain-core/src/Domain/Revision/RevisionMetadata.ts
  12. 8 0
      packages/domain-core/src/Domain/Revision/RevisionMetadataProps.ts
  13. 5 0
      packages/domain-core/src/Domain/UseCase/UseCaseInterface.ts
  14. 7 1
      packages/domain-core/src/Domain/index.ts
  15. 34 0
      packages/revisions/.env.sample
  16. 3 0
      packages/revisions/.eslintignore
  17. 6 0
      packages/revisions/.eslintrc
  18. 17 0
      packages/revisions/Dockerfile
  19. 70 0
      packages/revisions/bin/server.ts
  20. 22 0
      packages/revisions/docker/entrypoint.sh
  21. 11 0
      packages/revisions/jest.config.js
  22. 4 0
      packages/revisions/linter.tsconfig.json
  23. 66 0
      packages/revisions/package.json
  24. 174 0
      packages/revisions/src/Bootstrap/Container.ts
  25. 44 0
      packages/revisions/src/Bootstrap/DataSource.ts
  26. 24 0
      packages/revisions/src/Bootstrap/Env.ts
  27. 37 0
      packages/revisions/src/Bootstrap/Types.ts
  28. 34 0
      packages/revisions/src/Controller/RevisionsController.spec.ts
  29. 31 0
      packages/revisions/src/Controller/RevisionsController.ts
  30. 5 0
      packages/revisions/src/Domain/Revision/RevisionRepositoryInterface.ts
  31. 28 0
      packages/revisions/src/Domain/UseCase/GetRevisionsMetada/GetRevisionsMetada.spec.ts
  32. 20 0
      packages/revisions/src/Domain/UseCase/GetRevisionsMetada/GetRevisionsMetada.ts
  33. 3 0
      packages/revisions/src/Domain/UseCase/GetRevisionsMetada/GetRevisionsMetadaDTO.ts
  34. 3 0
      packages/revisions/src/Infra/Http/GetRevisionsMetadataRequestParams.ts
  35. 60 0
      packages/revisions/src/Infra/InversifyExpress/InversifyExpressApiGatewayAuthMiddleware.ts
  36. 9 0
      packages/revisions/src/Infra/InversifyExpress/InversifyExpressHealthCheckController.ts
  37. 22 0
      packages/revisions/src/Infra/InversifyExpress/InversifyExpressRevisionsController.ts
  38. 34 0
      packages/revisions/src/Infra/MySQL/MySQLRevisionRepository.ts
  39. 77 0
      packages/revisions/src/Infra/TypeORM/TypeORMRevision.ts
  40. 37 0
      packages/revisions/src/Mapping/RevisionMetadataPersistenceMapper.ts
  41. 13 0
      packages/revisions/tsconfig.json
  42. 17 0
      packages/revisions/wait-for.sh
  43. 3 0
      tsconfig.json
  44. 44 0
      yarn.lock

+ 46 - 0
.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 }}"

+ 51 - 0
.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<compat/typescript>::version=4.8.4&hash=701156"],\
+          ["winston", "npm:3.8.2"]\
+        ],\
+        "linkType": "SOFT"\
+      }]\
+    ]],\
     ["@standardnotes/scheduler-server", [\
       ["workspace:packages/scheduler", {\
         "packageLocation": "./packages/scheduler/",\

+ 2 - 0
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",

+ 21 - 0
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()
+  })
+})

+ 32 - 0
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<TimestampsProps> {
+  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<Timestamps> {
+    if (!(createdAt instanceof Date)) {
+      return Result.fail<Timestamps>(
+        `Could not create Timestamps. Creation date should be a date object, given: ${createdAt}`,
+      )
+    }
+    if (!(updatedAt instanceof Date)) {
+      return Result.fail<Timestamps>(
+        `Could not create Timestamps. Update date should be a date object, given: ${createdAt}`,
+      )
+    }
+
+    return Result.ok<Timestamps>(new Timestamps({ createdAt, updatedAt }))
+  }
+}

+ 4 - 0
packages/domain-core/src/Domain/Common/TimestampsProps.ts

@@ -0,0 +1,4 @@
+export interface TimestampsProps {
+  createdAt: Date
+  updatedAt: Date
+}

+ 3 - 3
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()
   })

+ 2 - 2
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()
     }
   })
 })

+ 0 - 4
packages/domain-core/src/Domain/Map/MapInterface.ts

@@ -1,4 +0,0 @@
-export interface MapInterface<T, U> {
-  toDomain(persistence: U): T
-  toProjection(domain: T): U
-}

+ 4 - 0
packages/domain-core/src/Domain/Mapping/MapperInterface.ts

@@ -0,0 +1,4 @@
+export interface MapperInterface<T, U> {
+  toDomain(projection: U): T
+  toProjection(domain: T): U
+}

+ 19 - 0
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<RevisionMetadataProps> {
+  get id(): UniqueEntityId {
+    return this._id
+  }
+
+  private constructor(props: RevisionMetadataProps, id?: UniqueEntityId) {
+    super(props, id)
+  }
+
+  static create(props: RevisionMetadataProps, id?: UniqueEntityId): Result<RevisionMetadata> {
+    return Result.ok<RevisionMetadata>(new RevisionMetadata(props, id))
+  }
+}

+ 8 - 0
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
+}

+ 5 - 0
packages/domain-core/src/Domain/UseCase/UseCaseInterface.ts

@@ -0,0 +1,5 @@
+import { Result } from '../Core/Result'
+
+export interface UseCaseInterface<T> {
+  execute(...args: any[]): Promise<Result<T>>
+}

+ 7 - 1
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'

+ 34 - 0
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

+ 3 - 0
packages/revisions/.eslintignore

@@ -0,0 +1,3 @@
+dist
+test-setup.ts
+data

+ 6 - 0
packages/revisions/.eslintrc

@@ -0,0 +1,6 @@
+{
+  "extends": "../../.eslintrc",
+  "parserOptions": {
+    "project": "./linter.tsconfig.json"
+  }
+}

+ 17 - 0
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" ]

+ 70 - 0
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<string, unknown>, _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}`)
+})

+ 22 - 0
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 "$@"

+ 11 - 0
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/'],
+}

+ 4 - 0
packages/revisions/linter.tsconfig.json

@@ -0,0 +1,4 @@
+{
+  "extends": "./tsconfig.json",
+  "exclude": ["dist", "test-setup.ts"]
+}

+ 66 - 0
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 <karolsojko@standardnotes.com>",
+  "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"
+  }
+}

+ 174 - 0
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<Container> {
+    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<winston.Logger>(TYPES.Logger).toConstantValue(logger)
+
+    if (env.get('SQS_AWS_REGION', true)) {
+      container.bind<AWS.SQS>(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<AWS.S3 | undefined>(TYPES.S3).toConstantValue(s3Client)
+
+    // Map
+    container
+      .bind<MapperInterface<RevisionMetadata, TypeORMRevision>>(TYPES.RevisionMetadataPersistenceMapper)
+      .toConstantValue(new RevisionMetadataPersistenceMapper())
+
+    // 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))
+    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<GetRevisionsMetada>(TYPES.GetRevisionsMetada)
+      .toConstantValue(new GetRevisionsMetada(container.get(TYPES.RevisionRepository)))
+
+    // Controller
+    container
+      .bind<RevisionsController>(TYPES.RevisionsController)
+      .toConstantValue(new RevisionsController(container.get(TYPES.GetRevisionsMetada), container.get(TYPES.Logger)))
+
+    // Handlers
+
+    // Services
+    container
+      .bind<TokenDecoderInterface<CrossServiceTokenData>>(TYPES.CrossServiceTokenDecoder)
+      .toConstantValue(new TokenDecoder<CrossServiceTokenData>(container.get(TYPES.AUTH_JWT_SECRET)))
+
+    // Middleware
+    container
+      .bind<InversifyExpressApiGatewayAuthMiddleware>(TYPES.ApiGatewayAuthMiddleware)
+      .to(InversifyExpressApiGatewayAuthMiddleware)
+
+    const eventHandlers: Map<string, DomainEventHandlerInterface> = new Map([])
+
+    if (env.get('SQS_QUEUE_URL', true)) {
+      container
+        .bind<DomainEventMessageHandlerInterface>(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<DomainEventSubscriberFactoryInterface>(TYPES.DomainEventSubscriberFactory)
+        .toConstantValue(
+          new SQSDomainEventSubscriberFactory(
+            container.get(TYPES.SQS),
+            container.get(TYPES.SQS_QUEUE_URL),
+            container.get(TYPES.DomainEventMessageHandler),
+          ),
+        )
+    } else {
+      container
+        .bind<DomainEventMessageHandlerInterface>(TYPES.DomainEventMessageHandler)
+        .toConstantValue(new RedisEventMessageHandler(eventHandlers, container.get(TYPES.Logger)))
+      container
+        .bind<DomainEventSubscriberFactoryInterface>(TYPES.DomainEventSubscriberFactory)
+        .toConstantValue(
+          new RedisDomainEventSubscriberFactory(
+            container.get(TYPES.Redis),
+            container.get(TYPES.DomainEventMessageHandler),
+            container.get(TYPES.REDIS_EVENTS_CHANNEL),
+          ),
+        )
+    }
+
+    return container
+  }
+}

+ 44 - 0
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: <LoggerOptions>env.get('DB_DEBUG_LEVEL'),
+})

+ 24 - 0
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 = <DotenvParseOutput>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 <string>process.env[key]
+  }
+}

+ 37 - 0
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

+ 34 - 0
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<GetRevisionsMetada>
+    getRevisionsMetadata.execute = jest.fn().mockReturnValue(Result.ok())
+
+    logger = {} as jest.Mocked<Logger>
+    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)
+  })
+})

+ 31 - 0
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<HttpResponse> {
+    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() },
+    }
+  }
+}

+ 5 - 0
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<Array<RevisionMetadata>>
+}

+ 28 - 0
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<RevisionRepositoryInterface>
+    revisionRepository.findMetadataByItemId = jest.fn().mockReturnValue([{} as jest.Mocked<RevisionMetadata>])
+  })
+
+  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()
+  })
+})

+ 20 - 0
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<RevisionMetadata[]> {
+  constructor(private revisionRepository: RevisionRepositoryInterface) {}
+
+  async execute(dto: GetRevisionsMetadaDTO): Promise<Result<RevisionMetadata[]>> {
+    const itemUuidOrError = Uuid.create(dto.itemUuid)
+    if (itemUuidOrError.isFailed()) {
+      return Result.fail<RevisionMetadata[]>(`Could not get revisions: ${itemUuidOrError.getError()}`)
+    }
+
+    const revisionsMetdata = await this.revisionRepository.findMetadataByItemId(itemUuidOrError.getValue())
+
+    return Result.ok<RevisionMetadata[]>(revisionsMetdata)
+  }
+}

+ 3 - 0
packages/revisions/src/Domain/UseCase/GetRevisionsMetada/GetRevisionsMetadaDTO.ts

@@ -0,0 +1,3 @@
+export interface GetRevisionsMetadaDTO {
+  itemUuid: string
+}

+ 3 - 0
packages/revisions/src/Infra/Http/GetRevisionsMetadataRequestParams.ts

@@ -0,0 +1,3 @@
+export interface GetRevisionsMetadataRequestParams {
+  itemUuid: string
+}

+ 60 - 0
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<CrossServiceTokenData>,
+    @inject(TYPES.Logger) private logger: Logger,
+  ) {
+    super()
+  }
+
+  async handler(request: Request, response: Response, next: NextFunction): Promise<void> {
+    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)
+    }
+  }
+}

+ 9 - 0
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<string> {
+    return 'OK'
+  }
+}

+ 22 - 0
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<results.JsonResult> {
+    const result = await this.revisionsController.getRevisions({
+      itemUuid: req.params.itemUuid,
+    })
+
+    return this.json(result.data, result.status)
+  }
+}

+ 34 - 0
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<TypeORMRevision>,
+    private revisionMapper: MapperInterface<RevisionMetadata, TypeORMRevision>,
+  ) {}
+
+  async findMetadataByItemId(itemUuid: Uuid): Promise<Array<RevisionMetadata>> {
+    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
+  }
+}

+ 77 - 0
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
+}

+ 37 - 0
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<RevisionMetadata, TypeORMRevision> {
+  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.')
+  }
+}

+ 13 - 0
packages/revisions/tsconfig.json

@@ -0,0 +1,13 @@
+{
+  "extends": "../../tsconfig.json",
+  "compilerOptions": {
+    "composite": true,
+    "outDir": "./dist",
+  },
+  "include": [
+    "src/**/*",
+    "bin/**/*",
+    "migrations/**/*",
+  ],
+  "references": []
+}

+ 17 - 0
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

+ 3 - 0
tsconfig.json

@@ -53,6 +53,9 @@
     {
       "path": "./packages/predicates"
     },
+    {
+      "path": "./packages/revisions"
+    },
     {
       "path": "./packages/scheduler"
     },

+ 44 - 0
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"