Преглед на файлове

feat: add event store package

Karol Sójko преди 3 години
родител
ревизия
84ff915a56

+ 126 - 0
.github/workflows/event-store.release.yml

@@ -0,0 +1,126 @@
+name: Event Store
+
+concurrency:
+  group: event-store
+  cancel-in-progress: true
+
+on:
+  push:
+    tags:
+      - '*standardnotes/event-store*'
+  workflow_dispatch:
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v3
+    - name: Set up Node
+      uses: actions/setup-node@v3
+      with:
+        registry-url: 'https://registry.npmjs.org'
+        node-version-file: '.nvmrc'
+    - run: yarn build
+    - run: yarn lint:event-store
+    - run: yarn test:event-store
+
+  publish-aws-ecr:
+    needs: test
+
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v3
+    - name: Build locally
+      run: yarn build
+    - name: Configure AWS credentials
+      uses: aws-actions/configure-aws-credentials@v1
+      with:
+        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+        aws-region: us-east-1
+    - name: Login to Amazon ECR
+      id: login-ecr
+      uses: aws-actions/amazon-ecr-login@v1
+    - name: Build, tag, and push image to Amazon ECR
+      id: build-image
+      env:
+        ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
+        ECR_REPOSITORY: event-store
+        IMAGE_TAG: ${{ github.sha }}
+      run: |
+        yarn docker build @standardnotes/event-store -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
+        docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
+        docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:latest
+        docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
+
+  publish-docker-hub:
+    needs: test
+
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v3
+    - name: Build locally
+      run: yarn build
+    - name: Login to Docker Hub
+      uses: docker/login-action@v2
+      with:
+        username: ${{ secrets.DOCKER_USERNAME }}
+        password: ${{ secrets.DOCKER_PASSWORD }}
+    - name: Build, tag, and push image to Docker Hub
+      run: |
+        yarn docker build @standardnotes/event-store -t standardnotes/event-store:${{ github.sha }}
+        docker push standardnotes/event-store:${{ github.sha }}
+        docker tag standardnotes/event-store:${{ github.sha }} standardnotes/event-store:latest
+        docker push standardnotes/event-store:latest
+
+  deploy-worker:
+    needs: publish-aws-ecr
+
+    runs-on: ubuntu-latest
+
+    steps:
+    - name: Configure AWS credentials
+      uses: aws-actions/configure-aws-credentials@v1
+      with:
+        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+        aws-region: us-east-1
+    - name: PROD - Download task definition
+      run: |
+        aws ecs describe-task-definition --task-definition event-store-prod --query taskDefinition > task-definition.json
+    - name: PROD - Fill in the new version in the Amazon ECS task definition
+      run: |
+        jq '(.containerDefinitions[] | select(.name=="event-store-prod") | .environment[] | select(.name=="VERSION")).value = "${{ github.sha }}"' task-definition.json > tmp.json && mv tmp.json task-definition.json
+    - name: PROD - Fill in the new image ID in the Amazon ECS task definition
+      id: task-def-prod
+      uses: aws-actions/amazon-ecs-render-task-definition@v1
+      with:
+        task-definition: task-definition.json
+        container-name: event-store-prod
+        image: ${{ secrets.AWS_ECR_REGISTRY }}/event-store:${{ github.sha }}
+    - name: PROD - Deploy Amazon ECS task definition
+      uses: aws-actions/amazon-ecs-deploy-task-definition@v1
+      with:
+        task-definition: ${{ steps.task-def-prod.outputs.task-definition }}
+        service: event-store-prod
+        cluster: prod
+        wait-for-service-stability: true
+
+  newrelic:
+    needs: [ deploy-worker ]
+
+    runs-on: ubuntu-latest
+
+    steps:
+      - 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_EVENT_STORE_PROD }}
+          revision: "${{ github.sha }}"
+          description: "Automated Deployment via Github Actions"
+          user: "${{ github.actor }}"

+ 45 - 0
.pnp.cjs

@@ -44,6 +44,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
         "name": "@standardnotes/domain-events-infra",\
         "name": "@standardnotes/domain-events-infra",\
         "reference": "workspace:packages/domain-events-infra"\
         "reference": "workspace:packages/domain-events-infra"\
       },\
       },\
+      {\
+        "name": "@standardnotes/event-store",\
+        "reference": "workspace:packages/event-store"\
+      },\
       {\
       {\
         "name": "@standardnotes/files-server",\
         "name": "@standardnotes/files-server",\
         "reference": "workspace:packages/files"\
         "reference": "workspace:packages/files"\
@@ -86,6 +90,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
       ["@standardnotes/common", ["workspace:packages/common"]],\
       ["@standardnotes/common", ["workspace:packages/common"]],\
       ["@standardnotes/domain-events", ["workspace:packages/domain-events"]],\
       ["@standardnotes/domain-events", ["workspace:packages/domain-events"]],\
       ["@standardnotes/domain-events-infra", ["workspace:packages/domain-events-infra"]],\
       ["@standardnotes/domain-events-infra", ["workspace:packages/domain-events-infra"]],\
+      ["@standardnotes/event-store", ["workspace:packages/event-store"]],\
       ["@standardnotes/files-server", ["workspace:packages/files"]],\
       ["@standardnotes/files-server", ["workspace:packages/files"]],\
       ["@standardnotes/predicates", ["workspace:packages/predicates"]],\
       ["@standardnotes/predicates", ["workspace:packages/predicates"]],\
       ["@standardnotes/scheduler-server", ["workspace:packages/scheduler"]],\
       ["@standardnotes/scheduler-server", ["workspace:packages/scheduler"]],\
@@ -2895,6 +2900,36 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
           "linkType": "HARD"\
           "linkType": "HARD"\
         }]\
         }]\
       ]],\
       ]],\
+      ["@standardnotes/event-store", [\
+        ["workspace:packages/event-store", {\
+          "packageLocation": "./packages/event-store/",\
+          "packageDependencies": [\
+            ["@standardnotes/event-store", "workspace:packages/event-store"],\
+            ["@standardnotes/domain-events", "workspace:packages/domain-events"],\
+            ["@standardnotes/domain-events-infra", "workspace:packages/domain-events-infra"],\
+            ["@standardnotes/time", "workspace:packages/time"],\
+            ["@types/ioredis", "npm:4.28.10"],\
+            ["@types/jest", "npm:28.1.4"],\
+            ["@types/newrelic", "npm:7.0.3"],\
+            ["@types/nodemailer", "npm:6.4.4"],\
+            ["@typescript-eslint/eslint-plugin", "virtual:04783e12400851b8a3d76e71495851cc94959db6e62f04cb0a31190080629440b182d8c8eb4d7f2b04e281912f2783a5fd4d2c3c6ab68d38b7097246c93f4c19#npm:5.30.5"],\
+            ["aws-sdk", "npm:2.1168.0"],\
+            ["dotenv", "npm:8.2.0"],\
+            ["eslint", "npm:8.19.0"],\
+            ["eslint-plugin-prettier", "virtual:04783e12400851b8a3d76e71495851cc94959db6e62f04cb0a31190080629440b182d8c8eb4d7f2b04e281912f2783a5fd4d2c3c6ab68d38b7097246c93f4c19#npm:4.2.1"],\
+            ["inversify", "npm:6.0.1"],\
+            ["ioredis", "npm:5.1.0"],\
+            ["jest", "virtual:e1128e9ebb31076ea8e955c00397fd108ee8bf0fb2df3b2a603c510b7014a507cfa360bccf848efc1ec8c431656aa94c5ad08bcec32950bdf1278d01cd890e4f#npm:28.1.2"],\
+            ["mysql2", "npm:2.3.3"],\
+            ["newrelic", "npm:8.14.1"],\
+            ["reflect-metadata", "npm:0.1.13"],\
+            ["ts-jest", "virtual:e1128e9ebb31076ea8e955c00397fd108ee8bf0fb2df3b2a603c510b7014a507cfa360bccf848efc1ec8c431656aa94c5ad08bcec32950bdf1278d01cd890e4f#npm:28.0.5"],\
+            ["typeorm", "virtual:31b5a94a105c89c9294c3d524a7f8929fe63ee5a2efadf21951ca4c0cfd2ecf02e8f4ef5a066bbda091f1e3a56e57c6749069a080618c96b22e51131a330fc4a#npm:0.3.7"],\
+            ["winston", "npm:3.3.3"]\
+          ],\
+          "linkType": "SOFT"\
+        }]\
+      ]],\
       ["@standardnotes/features", [\
       ["@standardnotes/features", [\
         ["npm:1.50.0", {\
         ["npm:1.50.0", {\
           "packageLocation": "./.yarn/cache/@standardnotes-features-npm-1.50.0-dd65714983-b61b50695b.zip/node_modules/@standardnotes/features/",\
           "packageLocation": "./.yarn/cache/@standardnotes-features-npm-1.50.0-dd65714983-b61b50695b.zip/node_modules/@standardnotes/features/",\
@@ -3609,6 +3644,16 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
           "linkType": "HARD"\
           "linkType": "HARD"\
         }]\
         }]\
       ]],\
       ]],\
+      ["@types/nodemailer", [\
+        ["npm:6.4.4", {\
+          "packageLocation": "./.yarn/cache/@types-nodemailer-npm-6.4.4-c5c500abe2-16ed1bad2c.zip/node_modules/@types/nodemailer/",\
+          "packageDependencies": [\
+            ["@types/nodemailer", "npm:6.4.4"],\
+            ["@types/node", "npm:18.0.3"]\
+          ],\
+          "linkType": "HARD"\
+        }]\
+      ]],\
       ["@types/normalize-package-data", [\
       ["@types/normalize-package-data", [\
         ["npm:2.4.1", {\
         ["npm:2.4.1", {\
           "packageLocation": "./.yarn/cache/@types-normalize-package-data-npm-2.4.1-c31c56ae6a-e87bccbf11.zip/node_modules/@types/normalize-package-data/",\
           "packageLocation": "./.yarn/cache/@types-normalize-package-data-npm-2.4.1-c31c56ae6a-e87bccbf11.zip/node_modules/@types/normalize-package-data/",\

BIN
.yarn/cache/@types-nodemailer-npm-6.4.4-c5c500abe2-16ed1bad2c.zip


+ 2 - 0
package.json

@@ -17,11 +17,13 @@
     "lint:syncing-server": "yarn workspace @standardnotes/syncing-server lint",
     "lint:syncing-server": "yarn workspace @standardnotes/syncing-server lint",
     "lint:files": "yarn workspace @standardnotes/files-server lint",
     "lint:files": "yarn workspace @standardnotes/files-server lint",
     "lint:api-gateway": "yarn workspace @standardnotes/api-gateway lint",
     "lint:api-gateway": "yarn workspace @standardnotes/api-gateway lint",
+    "lint:event-store": "yarn workspace @standardnotes/event-store lint",
     "test": "yarn workspaces foreach -p -j 10 --verbose run test",
     "test": "yarn workspaces foreach -p -j 10 --verbose run test",
     "test:auth": "yarn workspace @standardnotes/auth-server test",
     "test:auth": "yarn workspace @standardnotes/auth-server test",
     "test:scheduler": "yarn workspace @standardnotes/scheduler-server test",
     "test:scheduler": "yarn workspace @standardnotes/scheduler-server test",
     "test:syncing-server": "yarn workspace @standardnotes/syncing-server test",
     "test:syncing-server": "yarn workspace @standardnotes/syncing-server test",
     "test:files": "yarn workspace @standardnotes/files-server test",
     "test:files": "yarn workspace @standardnotes/files-server test",
+    "test:event-store": "yarn workspace @standardnotes/event-store test",
     "clean": "yarn workspaces foreach -p --verbose run clean",
     "clean": "yarn workspaces foreach -p --verbose run clean",
     "setup:env": "cp .env.sample .env && yarn workspaces foreach -p --verbose run setup:env",
     "setup:env": "cp .env.sample .env && yarn workspaces foreach -p --verbose run setup:env",
     "build": "yarn workspaces foreach -pt -j 10 --verbose run build",
     "build": "yarn workspaces foreach -pt -j 10 --verbose run build",

+ 0 - 1
packages/auth/package.json

@@ -8,7 +8,6 @@
   "description": "Auth Server",
   "description": "Auth Server",
   "main": "dist/src/index.js",
   "main": "dist/src/index.js",
   "typings": "dist/src/index.d.ts",
   "typings": "dist/src/index.d.ts",
-  "repository": "git@github.com:standardnotes/auth.git",
   "author": "Karol Sójko <karolsojko@standardnotes.com>",
   "author": "Karol Sójko <karolsojko@standardnotes.com>",
   "license": "AGPL-3.0-or-later",
   "license": "AGPL-3.0-or-later",
   "scripts": {
   "scripts": {

+ 24 - 0
packages/event-store/.env.sample

@@ -0,0 +1,24 @@
+LOG_LEVEL=debug
+NODE_ENV=development
+VERSION=development
+
+DB_HOST=127.0.0.1
+DB_REPLICA_HOST=127.0.0.1
+DB_PORT=3306
+DB_USERNAME=store
+DB_PASSWORD=changeme123
+DB_DATABASE=store
+DB_DEBUG_LEVEL=all # "all" | "query" | "schema" | "error" | "warn" | "info" | "log" | "migration"
+DB_MIGRATIONS_PATH=dist/migrations/*.js
+
+SQS_QUEUE_URL=
+SQS_AWS_REGION=
+
+# (Optional) New Relic Setup
+NEW_RELIC_ENABLED=false
+NEW_RELIC_APP_NAME="Event Store"
+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

+ 2 - 0
packages/event-store/.eslintignore

@@ -0,0 +1,2 @@
+dist
+test-setup.ts

+ 6 - 0
packages/event-store/.eslintrc

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

+ 27 - 0
packages/event-store/Dockerfile

@@ -0,0 +1,27 @@
+FROM node:16.15.1-alpine AS builder
+
+# Install dependencies for building native libraries
+RUN apk add --update git openssh-client python3 alpine-sdk
+
+WORKDIR /workspace
+
+# docker-build plugin copies everything needed for `yarn install` to `manifests` folder.
+COPY manifests ./
+
+RUN yarn install --immutable
+
+FROM node:16.15.1-alpine
+
+RUN apk add --update curl
+
+WORKDIR /workspace
+
+# Copy the installed dependencies from the previous stage.
+COPY --from=builder /workspace ./
+
+# docker-build plugin runs `yarn pack` in all workspace dependencies and copies them to `packs` folder.
+COPY packs ./
+
+ENTRYPOINT [ "/workspace/packages/event-store/docker/entrypoint.sh" ]
+
+CMD [ "start-worker" ]

+ 25 - 0
packages/event-store/bin/worker.ts

@@ -0,0 +1,25 @@
+import 'reflect-metadata'
+
+import 'newrelic'
+
+import { Logger } from 'winston'
+
+import { ContainerConfigLoader } from '../src/Bootstrap/Container'
+import TYPES from '../src/Bootstrap/Types'
+import { Env } from '../src/Bootstrap/Env'
+import { DomainEventSubscriberFactoryInterface } from '@standardnotes/domain-events'
+
+const container = new ContainerConfigLoader()
+void container.load().then((container) => {
+  const env: Env = new Env()
+  env.load()
+
+  const logger: Logger = container.get(TYPES.Logger)
+
+  logger.info('Starting worker...')
+
+  const subscriberFactory: DomainEventSubscriberFactoryInterface = container.get(TYPES.DomainEventSubscriberFactory)
+  subscriberFactory.create().start()
+
+  setInterval(() => logger.info('Alive and kicking!'), 20 * 60 * 1000)
+})

+ 17 - 0
packages/event-store/docker/entrypoint.sh

@@ -0,0 +1,17 @@
+#!/bin/sh
+set -e
+
+COMMAND=$1 && shift 1
+
+case "$COMMAND" in
+  'start-worker' )
+    echo "Starting Worker..."
+    yarn workspace @standardnotes/event-store worker
+    ;;
+
+   * )
+    echo "Unknown command"
+    ;;
+esac
+
+exec "$@"

+ 17 - 0
packages/event-store/jest.config.js

@@ -0,0 +1,17 @@
+// eslint-disable-next-line @typescript-eslint/no-var-requires
+const base = require('../../jest.config');
+
+module.exports = {
+  ...base,
+  globals: {
+    'ts-jest': {
+      tsconfig: 'tsconfig.json',
+    },
+  },
+  coveragePathIgnorePatterns: [
+    '/Bootstrap/'
+  ],
+  setupFilesAfterEnv: [
+    './test-setup.ts'
+  ]
+};

+ 4 - 0
packages/event-store/linter.tsconfig.json

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

+ 16 - 0
packages/event-store/migrations/1639394147420-init_database.ts

@@ -0,0 +1,16 @@
+import { MigrationInterface, QueryRunner } from 'typeorm'
+
+export class initDatabase1639394147420 implements MigrationInterface {
+  name = 'initDatabase1639394147420'
+
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(
+      'CREATE TABLE `events` (`uuid` varchar(36) NOT NULL, `user_identifier` varchar(255) NOT NULL, `user_identifier_type` varchar(255) NOT NULL, `event_type` varchar(255) NOT NULL, `event_payload` text NOT NULL, `timestamp` bigint NOT NULL, INDEX `index_events_on_user_identifier` (`user_identifier`), PRIMARY KEY (`uuid`)) ENGINE=InnoDB',
+    )
+  }
+
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query('DROP INDEX `index_events_on_user_identifier` ON `events`')
+    await queryRunner.query('DROP TABLE `events`')
+  }
+}

+ 47 - 0
packages/event-store/package.json

@@ -0,0 +1,47 @@
+{
+  "name": "@standardnotes/event-store",
+  "version": "1.0.0",
+  "description": "Event Store Service",
+  "private": true,
+  "main": "dist/src/index.js",
+  "typings": "dist/src/index.d.ts",
+  "engines": {
+    "node": ">=16.0.0 <17.0.0"
+  },
+  "scripts": {
+    "clean": "rm -fr dist",
+    "prebuild": "yarn clean",
+    "build": "tsc --rootDir ./",
+    "lint": "eslint . --ext .ts",
+    "pretest": "yarn lint && yarn build",
+    "test": "jest --coverage --config=./jest.config.js --maxWorkers=50%",
+    "worker": "yarn node dist/bin/worker.js"
+  },
+  "author": "Karol Sójko <karolsojko@standardnotes.com>",
+  "license": "AGPL-3.0-or-later",
+  "devDependencies": {
+    "@types/ioredis": "^4.28.10",
+    "@types/jest": "^28.1.3",
+    "@types/newrelic": "^7.0.3",
+    "@types/nodemailer": "^6.4.1",
+    "@typescript-eslint/eslint-plugin": "^5.30.5",
+    "eslint": "^8.14.0",
+    "eslint-plugin-prettier": "^4.2.1",
+    "jest": "^28.1.1",
+    "ts-jest": "^28.0.1"
+  },
+  "dependencies": {
+    "@standardnotes/domain-events": "workspace:*",
+    "@standardnotes/domain-events-infra": "workspace:*",
+    "@standardnotes/time": "workspace:*",
+    "aws-sdk": "^2.1159.0",
+    "dotenv": "8.2.0",
+    "inversify": "^6.0.1",
+    "ioredis": "^5.0.6",
+    "mysql2": "^2.3.3",
+    "newrelic": "^8.14.1",
+    "reflect-metadata": "0.1.13",
+    "typeorm": "^0.3.6",
+    "winston": "3.3.3"
+  }
+}

+ 97 - 0
packages/event-store/src/Bootstrap/Container.ts

@@ -0,0 +1,97 @@
+import * as AWS from 'aws-sdk'
+import * as winston from 'winston'
+import { Container } from 'inversify'
+import { Event } from '../Domain/Event/Event'
+import { Env } from './Env'
+import TYPES from './Types'
+import {
+  DomainEventHandlerInterface,
+  DomainEventMessageHandlerInterface,
+  DomainEventSubscriberFactoryInterface,
+} from '@standardnotes/domain-events'
+import {
+  SQSDomainEventSubscriberFactory,
+  SQSEventMessageHandler,
+  SQSNewRelicEventMessageHandler,
+} from '@standardnotes/domain-events-infra'
+import { Timer, TimerInterface } from '@standardnotes/time'
+import { EventHandler } from '../Domain/Handler/EventHandler'
+import { AppDataSource } from './DataSource'
+import { Repository } from 'typeorm'
+
+export class ContainerConfigLoader {
+  async load(): Promise<Container> {
+    const env: Env = new Env()
+    env.load()
+
+    const container = new Container()
+
+    await AppDataSource.initialize()
+
+    container.bind<AWS.SQS>(TYPES.SQS).toConstantValue(
+      new AWS.SQS({
+        apiVersion: 'latest',
+        region: env.get('SQS_AWS_REGION'),
+      }),
+    )
+
+    const logger = winston.createLogger({
+      level: env.get('LOG_LEVEL') || 'info',
+      format: winston.format.combine(winston.format.splat(), winston.format.json()),
+      transports: [new winston.transports.Console({ level: env.get('LOG_LEVEL') || 'info' })],
+    })
+    container.bind<winston.Logger>(TYPES.Logger).toConstantValue(logger)
+
+    container.bind<TimerInterface>(TYPES.Timer).toConstantValue(new Timer())
+
+    // env vars
+    container.bind(TYPES.SQS_AWS_REGION).toConstantValue(env.get('SQS_AWS_REGION'))
+    container.bind(TYPES.SQS_QUEUE_URL).toConstantValue(env.get('SQS_QUEUE_URL'))
+
+    // Handlers
+    container.bind<EventHandler>(TYPES.EventHandler).to(EventHandler)
+
+    const eventHandlers: Map<string, DomainEventHandlerInterface> = new Map([
+      ['USER_REGISTERED', container.get(TYPES.EventHandler)],
+      ['ACCOUNT_DELETION_REQUESTED', container.get(TYPES.EventHandler)],
+      ['SUBSCRIPTION_PURCHASED', container.get(TYPES.EventHandler)],
+      ['SUBSCRIPTION_CANCELLED', container.get(TYPES.EventHandler)],
+      ['SUBSCRIPTION_RENEWED', container.get(TYPES.EventHandler)],
+      ['SUBSCRIPTION_REFUNDED', container.get(TYPES.EventHandler)],
+      ['SUBSCRIPTION_SYNC_REQUESTED', container.get(TYPES.EventHandler)],
+      ['SUBSCRIPTION_EXPIRED', container.get(TYPES.EventHandler)],
+      ['EXTENSION_KEY_GRANTED', container.get(TYPES.EventHandler)],
+      ['SUBSCRIPTION_REASSIGNED', container.get(TYPES.EventHandler)],
+      ['USER_EMAIL_CHANGED', container.get(TYPES.EventHandler)],
+      ['FILE_UPLOADED', container.get(TYPES.EventHandler)],
+      ['FILE_REMOVED', container.get(TYPES.EventHandler)],
+      ['LISTED_ACCOUNT_REQUESTED', container.get(TYPES.EventHandler)],
+      ['LISTED_ACCOUNT_CREATED', container.get(TYPES.EventHandler)],
+      ['LISTED_ACCOUNT_DELETED', container.get(TYPES.EventHandler)],
+      ['USER_SIGNED_IN', container.get(TYPES.EventHandler)],
+      ['SHARED_SUBSCRIPTION_INVITATION_CREATED', container.get(TYPES.EventHandler)],
+    ])
+
+    // ORM
+    container.bind<Repository<Event>>(TYPES.ORMEventRepository).toConstantValue(AppDataSource.getRepository(Event))
+
+    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),
+        ),
+      )
+
+    return container
+  }
+}

+ 40 - 0
packages/event-store/src/Bootstrap/DataSource.ts

@@ -0,0 +1,40 @@
+import { DataSource, LoggerOptions } from 'typeorm'
+import { Event } from '../Domain/Event/Event'
+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',
+  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,
+  },
+  entities: [Event],
+  migrations: [env.get('DB_MIGRATIONS_PATH')],
+  migrationsRun: true,
+  logging: <LoggerOptions>env.get('DB_DEBUG_LEVEL'),
+})

+ 24 - 0
packages/event-store/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]
+  }
+}

+ 17 - 0
packages/event-store/src/Bootstrap/Types.ts

@@ -0,0 +1,17 @@
+const TYPES = {
+  Logger: Symbol.for('Logger'),
+  SQS: Symbol.for('SQS'),
+  // env vars
+  SQS_QUEUE_URL: Symbol.for('SQS_QUEUE_URL'),
+  SQS_AWS_REGION: Symbol.for('SQS_AWS_REGION'),
+  // Handlers
+  DomainEventSubscriberFactory: Symbol.for('DomainEventSubscriberFactory'),
+  DomainEventMessageHandler: Symbol.for('DomainEventMessageHandler'),
+  EventHandler: Symbol.for('EventHandler'),
+  // ORM
+  ORMEventRepository: Symbol.for('ORMEventRepository'),
+  // Services
+  Timer: Symbol.for('Timer'),
+}
+
+export default TYPES

+ 38 - 0
packages/event-store/src/Domain/Event/Event.ts

@@ -0,0 +1,38 @@
+import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'
+
+@Entity({ name: 'events' })
+export class Event {
+  @PrimaryGeneratedColumn('uuid')
+  declare uuid: string
+
+  @Column({
+    name: 'user_identifier',
+    length: 255,
+  })
+  @Index('index_events_on_user_identifier')
+  declare userIdentifier: string
+
+  @Column({
+    name: 'user_identifier_type',
+    length: 255,
+  })
+  declare userIdentifierType: string
+
+  @Column({
+    name: 'event_type',
+    length: 255,
+  })
+  declare eventType: string
+
+  @Column({
+    name: 'event_payload',
+    type: 'text',
+  })
+  declare eventPayload: string
+
+  @Column({
+    name: 'timestamp',
+    type: 'bigint',
+  })
+  declare timestamp: number
+}

+ 76 - 0
packages/event-store/src/Domain/Handler/EventHandler.spec.ts

@@ -0,0 +1,76 @@
+import 'reflect-metadata'
+
+import { TimerInterface } from '@standardnotes/time'
+import { Repository } from 'typeorm'
+import { EventHandler } from './EventHandler'
+import { Event } from '../Event/Event'
+import { Logger } from 'winston'
+import { DomainEventInterface } from '@standardnotes/domain-events'
+
+describe('EventHandler', () => {
+  let timer: TimerInterface
+  let repository: Repository<Event>
+  let logger: Logger
+
+  const createHandler = () => new EventHandler(timer, repository, logger)
+
+  beforeEach(() => {
+    timer = {} as jest.Mocked<TimerInterface>
+    timer.convertStringDateToMicroseconds = jest.fn().mockReturnValue(1)
+
+    repository = {} as jest.Mocked<Repository<Event>>
+    repository.save = jest.fn()
+
+    logger = {} as jest.Mocked<Logger>
+    logger.debug = jest.fn()
+    logger.error = jest.fn()
+  })
+
+  it('should persist as event in the store', async () => {
+    const event = {
+      type: 'test',
+      createdAt: new Date(2),
+      meta: {
+        correlation: {
+          userIdentifier: '1-2-3',
+          userIdentifierType: 'uuid',
+        },
+      },
+      payload: {
+        foo: 'bar',
+      },
+    } as jest.Mocked<DomainEventInterface>
+    await createHandler().handle(event)
+
+    expect(repository.save).toHaveBeenCalledWith({
+      eventType: 'test',
+      timestamp: 1,
+      userIdentifier: '1-2-3',
+      userIdentifierType: 'uuid',
+      eventPayload: '{"foo":"bar"}',
+    })
+  })
+
+  it('should inform about failure to saven the event in the store', async () => {
+    const event = {
+      type: 'test',
+      createdAt: new Date(2),
+      meta: {
+        correlation: {
+          userIdentifier: '1-2-3',
+          userIdentifierType: 'uuid',
+        },
+      },
+      payload: {
+        foo: 'bar',
+      },
+    } as jest.Mocked<DomainEventInterface>
+    repository.save = jest.fn().mockImplementation(() => {
+      throw new Error('Ooops')
+    })
+
+    await createHandler().handle(event)
+
+    expect(logger.error).toHaveBeenCalledWith('Could not store event %O in the event store: %s', event, 'Ooops')
+  })
+})

+ 33 - 0
packages/event-store/src/Domain/Handler/EventHandler.ts

@@ -0,0 +1,33 @@
+import { DomainEventHandlerInterface, DomainEventInterface } from '@standardnotes/domain-events'
+import { TimerInterface } from '@standardnotes/time'
+import { inject, injectable } from 'inversify'
+import { Repository } from 'typeorm'
+import { Logger } from 'winston'
+import TYPES from '../../Bootstrap/Types'
+import { Event } from '../Event/Event'
+
+@injectable()
+export class EventHandler implements DomainEventHandlerInterface {
+  constructor(
+    @inject(TYPES.Timer) private timer: TimerInterface,
+    @inject(TYPES.ORMEventRepository) private eventRepository: Repository<Event>,
+    @inject(TYPES.Logger) private logger: Logger,
+  ) {}
+
+  async handle(event: DomainEventInterface): Promise<void> {
+    this.logger.debug('Handling event: %O', event)
+
+    try {
+      const storedEvent = new Event()
+      storedEvent.eventType = event.type
+      storedEvent.userIdentifier = event.meta.correlation.userIdentifier
+      storedEvent.userIdentifierType = event.meta.correlation.userIdentifierType
+      storedEvent.eventPayload = JSON.stringify(event.payload)
+      storedEvent.timestamp = this.timer.convertStringDateToMicroseconds(event.createdAt.toString())
+
+      await this.eventRepository.save(storedEvent)
+    } catch (error) {
+      this.logger.error('Could not store event %O in the event store: %s', event, (error as Error).message)
+    }
+  }
+}

+ 0 - 0
packages/event-store/test-setup.ts


+ 13 - 0
packages/event-store/tsconfig.json

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

+ 3 - 0
tsconfig.json

@@ -40,6 +40,9 @@
     {
     {
       "path": "./packages/domain-events-infra"
       "path": "./packages/domain-events-infra"
     },
     },
+    {
+      "path": "./packages/event-store"
+    },
     {
     {
       "path": "./packages/files"
       "path": "./packages/files"
     },
     },

+ 38 - 1
yarn.lock

@@ -2159,6 +2159,34 @@ __metadata:
   languageName: node
   languageName: node
   linkType: hard
   linkType: hard
 
 
+"@standardnotes/event-store@workspace:packages/event-store":
+  version: 0.0.0-use.local
+  resolution: "@standardnotes/event-store@workspace:packages/event-store"
+  dependencies:
+    "@standardnotes/domain-events": "workspace:*"
+    "@standardnotes/domain-events-infra": "workspace:*"
+    "@standardnotes/time": "workspace:*"
+    "@types/ioredis": ^4.28.10
+    "@types/jest": ^28.1.3
+    "@types/newrelic": ^7.0.3
+    "@types/nodemailer": ^6.4.1
+    "@typescript-eslint/eslint-plugin": ^5.30.5
+    aws-sdk: ^2.1159.0
+    dotenv: 8.2.0
+    eslint: ^8.14.0
+    eslint-plugin-prettier: ^4.2.1
+    inversify: ^6.0.1
+    ioredis: ^5.0.6
+    jest: ^28.1.1
+    mysql2: ^2.3.3
+    newrelic: ^8.14.1
+    reflect-metadata: 0.1.13
+    ts-jest: ^28.0.1
+    typeorm: ^0.3.6
+    winston: 3.3.3
+  languageName: unknown
+  linkType: soft
+
 "@standardnotes/features@npm:1.50.0, @standardnotes/features@npm:^1.36.3, @standardnotes/features@npm:^1.47.0":
 "@standardnotes/features@npm:1.50.0, @standardnotes/features@npm:^1.36.3, @standardnotes/features@npm:^1.47.0":
   version: 1.50.0
   version: 1.50.0
   resolution: "@standardnotes/features@npm:1.50.0"
   resolution: "@standardnotes/features@npm:1.50.0"
@@ -2796,6 +2824,15 @@ __metadata:
   languageName: node
   languageName: node
   linkType: hard
   linkType: hard
 
 
+"@types/nodemailer@npm:^6.4.1":
+  version: 6.4.4
+  resolution: "@types/nodemailer@npm:6.4.4"
+  dependencies:
+    "@types/node": "*"
+  checksum: 16ed1bad2cd8471fd3b026471e234da33ba3b65935dc44b31be3145eff7bdb067eb4d08ec4b41d23339b988075299abc1a0c0fe77b99f04ca235827bca95af81
+  languageName: node
+  linkType: hard
+
 "@types/normalize-package-data@npm:^2.4.0":
 "@types/normalize-package-data@npm:^2.4.0":
   version: 2.4.1
   version: 2.4.1
   resolution: "@types/normalize-package-data@npm:2.4.1"
   resolution: "@types/normalize-package-data@npm:2.4.1"
@@ -2912,7 +2949,7 @@ __metadata:
   languageName: node
   languageName: node
   linkType: hard
   linkType: hard
 
 
-"@typescript-eslint/eslint-plugin@npm:^5.12.1, @typescript-eslint/eslint-plugin@npm:^5.29.0, @typescript-eslint/eslint-plugin@npm:^5.30.0":
+"@typescript-eslint/eslint-plugin@npm:^5.12.1, @typescript-eslint/eslint-plugin@npm:^5.29.0, @typescript-eslint/eslint-plugin@npm:^5.30.0, @typescript-eslint/eslint-plugin@npm:^5.30.5":
   version: 5.30.5
   version: 5.30.5
   resolution: "@typescript-eslint/eslint-plugin@npm:5.30.5"
   resolution: "@typescript-eslint/eslint-plugin@npm:5.30.5"
   dependencies:
   dependencies: