├── .devcontainer ├── Dockerfile ├── base.Dockerfile └── devcontainer.json ├── .env-template ├── .eslintrc.js ├── .github └── workflows │ └── test-ci.yml ├── .gitignore ├── .prettierrc.js ├── .vscode └── settings.json ├── CHANGELOG.md ├── README.md ├── package-lock.json ├── package.json ├── src ├── entities │ ├── index.ts │ └── match.ts ├── index.ts ├── postgres.ts └── sequelize │ └── index.ts ├── test ├── docker-compose.yml ├── jest.config.js ├── mock-data │ ├── log-entry.mock.ts │ ├── match.mock.ts │ └── state.mock.ts ├── src │ ├── connect.test.ts │ ├── constructor.test.ts │ ├── create-match.test.ts │ ├── fetch.test.ts │ ├── list-matches.test.ts │ ├── set-metadata.test.ts │ ├── set-state.test.ts │ └── wipe.test.ts └── test-postgres-store.ts └── tsconfig.json /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 18, 16, 14, 18-bullseye, 16-bullseye, 14-bullseye, 18-buster, 16-buster, 14-buster 2 | ARG VARIANT=16-bullseye 3 | FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT} 4 | 5 | # [Optional] Uncomment this section to install additional OS packages. 6 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 7 | # && apt-get -y install --no-install-recommends 8 | 9 | # [Optional] Uncomment if you want to install an additional version of node using nvm 10 | # ARG EXTRA_NODE_VERSION=10 11 | # RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}" 12 | 13 | # [Optional] Uncomment if you want to install more global node packages 14 | # RUN su node -c "npm install -g " 15 | -------------------------------------------------------------------------------- /.devcontainer/base.Dockerfile: -------------------------------------------------------------------------------- 1 | # [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 18, 16, 14, 18-bullseye, 16-bullseye, 14-bullseye, 18-buster, 16-buster, 14-buster 2 | ARG VARIANT=16-bullseye 3 | FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-${VARIANT} 4 | 5 | # Install tslint, typescript. eslint is installed by javascript image 6 | ARG NODE_MODULES="tslint-to-eslint-config typescript" 7 | COPY library-scripts/meta.env /usr/local/etc/vscode-dev-containers 8 | RUN su node -c "umask 0002 && npm install -g ${NODE_MODULES}" \ 9 | && npm cache clean --force > /dev/null 2>&1 10 | 11 | # [Optional] Uncomment this section to install additional OS packages. 12 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 13 | # && apt-get -y install --no-install-recommends 14 | 15 | # [Optional] Uncomment if you want to install an additional version of node using nvm 16 | # ARG EXTRA_NODE_VERSION=10 17 | # RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}" 18 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.245.2/containers/typescript-node 3 | { 4 | "name": "Node.js & TypeScript", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | // Update 'VARIANT' to pick a Node version: 18, 16, 14. 8 | // Append -bullseye or -buster to pin to an OS version. 9 | // Use -bullseye variants on local on arm64/Apple Silicon. 10 | "args": { 11 | "VARIANT": "16-bullseye" 12 | } 13 | }, 14 | 15 | // Configure tool-specific properties. 16 | "customizations": { 17 | // Configure properties specific to VS Code. 18 | "vscode": { 19 | // Add the IDs of extensions you want installed when the container is created. 20 | "extensions": [ 21 | "dbaeumer.vscode-eslint", 22 | "mhutchie.git-graph", 23 | "eamodio.gitlens" 24 | ] 25 | } 26 | }, 27 | 28 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 29 | // "forwardPorts": [], 30 | 31 | // Use 'postCreateCommand' to run commands after the container is created. 32 | // "postCreateCommand": "yarn install", 33 | 34 | // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 35 | "remoteUser": "node", 36 | "features": { 37 | "docker-from-docker": "latest", 38 | "git": "os-provided" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.env-template: -------------------------------------------------------------------------------- 1 | DB_HOST=host.docker.internal 2 | DB_PORT=5432 3 | DB_DATABASE=postgres 4 | DB_USER=postgres 5 | DB_PASSWORD=postgres 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // based on https://github.com/iamturns/create-exposed-app/blob/master/.eslintrc.js 2 | module.exports = { 3 | parser: "@typescript-eslint/parser", // allows to lint typescript 4 | plugins: [ 5 | "@typescript-eslint", // allows for TypeScript-specific linting rules to run. 6 | "eslint-comments", //Additional ESLint rules for ESLint directive comments (e.g. //eslint-disable-line). 7 | // "jest", // rules specific for testing with jest 8 | "import", 9 | "prettier", //Runs Prettier as an ESLint rule and reports differences as individual ESLint issues. 10 | ], 11 | extends: [ 12 | "plugin:@typescript-eslint/recommended", 13 | // "plugin:jest/recommended", // use recommended jest rules 14 | "prettier", // eslint-config-prettier Turns off all rules that are unnecessary or might conflict with Prettier. 15 | "plugin:prettier/recommended", // extend eslint-config-prettier rules 16 | "prettier/@typescript-eslint", // eslint-config-prettier turns of prettier-conflicting of plugin:@typescript-eslint/recommended 17 | ], 18 | parserOptions: { 19 | ecmaVersion: 2018, 20 | sourceType: "module", 21 | }, 22 | 23 | env: { 24 | node: true, 25 | browser: true, 26 | jest: true, 27 | }, 28 | ignorePatterns: [".cache/**/*", "lib/**/*"], 29 | rules: { 30 | // https://basarat.gitbooks.io/typescript/docs/tips/defaultIsBad.html 31 | "import/prefer-default-export": "off", 32 | "import/no-default-export": "error", 33 | // Use function hoisting to improve code readability 34 | "no-use-before-define": [ 35 | "error", 36 | { functions: false, classes: true, variables: true }, 37 | ], 38 | // Makes no sense to allow type inferrence for expression parameters, but require typing the response 39 | "@typescript-eslint/explicit-function-return-type": [ 40 | "error", 41 | { allowExpressions: true, allowTypedFunctionExpressions: true }, 42 | ], 43 | "@typescript-eslint/no-use-before-define": [ 44 | "error", 45 | { functions: false, classes: true, variables: true, typedefs: true }, 46 | ], 47 | // Common abbreviations are known and readable 48 | // "unicorn/prevent-abbreviations": "off", 49 | "@typescript-eslint/no-implied-eval": "off", 50 | "@typescript-eslint/no-throw-literal": "off", 51 | "@typescript-eslint/no-non-null-assertion": "off", 52 | "consistent-return": "off", 53 | // "jest/expect-expect": ["error", { assertFunctionNames: ["expect*"] }], 54 | // "react/prop-types": "off", 55 | "eslint-comments/no-duplicate-disable": "error", 56 | "eslint-comments/no-unlimited-disable": "error", 57 | "eslint-comments/no-unused-enable": "error", 58 | }, 59 | }; 60 | -------------------------------------------------------------------------------- /.github/workflows/test-ci.yml: -------------------------------------------------------------------------------- 1 | name: Test CI 2 | 3 | on: [push] 4 | 5 | 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | 10 | env: 11 | DB_HOST: localhost 12 | DB_PORT: 5432 13 | DB_DATABASE: postgres 14 | DB_USER: postgres 15 | DB_PASSWORD: postgres 16 | 17 | services: 18 | postgres: 19 | image: postgres:12-alpine 20 | env: 21 | POSTGRES_USER: postgres 22 | POSTGRES_PASSWORD: postgres 23 | POSTGRES_DB: postgres 24 | ports: 25 | - 5432:5432 26 | options: >- 27 | --health-cmd pg_isready 28 | --health-interval 10s 29 | --health-timeout 5s 30 | --health-retries 5 31 | 32 | steps: 33 | - uses: actions/checkout@v2 34 | - name: Use Node.js 16 35 | uses: actions/setup-node@v2 36 | with: 37 | node-version: 16.x 38 | cache: 'npm' 39 | 40 | - name: Install Dependencies 41 | run: npm ci 42 | 43 | - name: Typecheck 44 | run: npm run typecheck 45 | 46 | - name: Lint 47 | run: npm run lint 48 | 49 | - name: Run Unit Tests 50 | run: npm run test:cov -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | .env 4 | test/coverage -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll": true 4 | }, 5 | "editor.formatOnSave": false, 6 | "files.eol": "\n", 7 | "editor.tabSize": 2, 8 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.0.14] - 2022-10-19 4 | 5 | - add tests and CI 6 | - fix security issues 7 | - update README 8 | 9 | ## [1.0.13] - 2020-12-23 10 | 11 | - restructure dist files for simpler imports 12 | - lower boardgame.io peer dependency to >=0.40.0 13 | 14 | ## [1.0.12] - 2020-12-21 15 | 16 | - fix package.json main entry 17 | 18 | ## [1.0.11] - 2020-12-21 19 | 20 | - re-export sequelize package 21 | 22 | ## [1.0.10] - 2020-12-20 23 | 24 | - use latest boardgame.io and sequelize versions 25 | - consistent naming: "game" -> "match" 26 | - provide public getter for sequelize instance 27 | 28 | ## [1.0.9] - 2020-11-24 29 | 30 | - fix support for boardgame.io >=0.40 31 | 32 | ## [1.0.8] - 2020-09-27 33 | 34 | - allow extra options when using URI 35 | 36 | ## [1.0.7] - 2020-08-10 37 | 38 | - Make players an empty array rather than null or undefined 39 | 40 | ## [1.0.6] - 2020-07-18 41 | 42 | - fix setMetadata error when createdAt / updatedAt args are not specified 43 | 44 | ## [1.0.5] - 2020-07-17 45 | 46 | - provide ability to filter match list (feature coming with boardgame.io 0.40.x) 47 | 48 | ## [1.0.4] - 2020-07-17 49 | 50 | - fix error when `fetch` finds no match 51 | 52 | ## [1.0.3] - 2020-06-05 53 | 54 | - allow usage with URI argument or options object -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `bgio-postgres` - PostgreSQL storage adapter for boardgame.io 2 | 3 | ## Usage 4 | 5 | You can use the `PostgresStore` in two ways. 6 | Either provide credentials using a URI as the first argument, or by using an options object. 7 | 8 | ```typescript 9 | import { Server } from "boardgame.io/server"; 10 | import { PostgresStore } from "bgio-postgres"; 11 | 12 | // EITHER provide a URI 13 | const db = new PostgresStore("postgresql://:@/"); 14 | 15 | // OR provide options 16 | const db = new PostgresStore({ 17 | database: "database", 18 | username: "username", 19 | password: "password", 20 | host: "host", 21 | }); 22 | 23 | const server = Server({ 24 | games: [...], 25 | db, 26 | }); 27 | ``` 28 | 29 | ## Optional Parameters 30 | 31 | This adapter uses [Sequelize][sequelize] as the ORM. Any additional options provided to `PostgresStore` will be passed to the initialization arguments of the underlying Sequelize instance. 32 | 33 | ```typescript 34 | // EITHER provide options after the URI... 35 | const db = new PostgresStore( 36 | "postgresql://:@/", 37 | { 38 | logging: myLogger, 39 | timezone: '+00:00', 40 | } 41 | ); 42 | 43 | // ...OR provide addition options with the credentials. 44 | const db = new PostgresStore({ 45 | database: "database", 46 | username: "username", 47 | password: "password", 48 | host: "host", 49 | logging: myLogger, 50 | timezone: '+00:00', 51 | }); 52 | 53 | ``` 54 | 55 | The full list of available options is documented in the [Sequelize API Docs][class-sequelize]. 56 | 57 | [sequelize]: https://sequelize.org/master/ 58 | [class-sequelize]: https://sequelize.readthedocs.io/en/latest/api/sequelize/#class-sequelize 59 | 60 | ## Using with MySQL or other databases 61 | Because Sequelize is used by the adapter under the hood, which can also be used by other database models, it is in theory possible for this adapter to be used to connect to any of the supported sequelize databases. _HOWEVER_, there are a few important caveats: 62 | - this adapter utilizes JSON data types to persist storage, which is not supported by all database models. At the time of writing this, [Sequelize recognizes the JSON datatype for MySQL, Postgres and SQLite](https://sequelize.org/api/v6/class/src/data-types.js~jsontype) 63 | - This library was not made with other database models in mind. While there have been reports of it working with MySQL, any reported issues with models other than Postgres will not be addressed. That being said, there doesn't seem to be a reason it should work any differently. 64 | 65 | In order to use this adapter with another database, first install a node client for that database as a dependency (for example, `npm install mysql2` for mysql). 66 | 67 | Second, either provide credentials using a URI as the first argument: 68 | 69 | ```typescript 70 | const db = new PostgresStore("mysql://:@/"); 71 | ``` 72 | 73 | or by using an options object. In this case, the option `dialect: ""` **_must be included_**. 74 | 75 | ```typescript 76 | const db = new PostgresStore({ 77 | database: "database", 78 | username: "username", 79 | password: "password", 80 | host: "host", 81 | dialect: "mysql", 82 | }); 83 | ``` 84 | 85 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bgio-postgres", 3 | "version": "1.0.14", 4 | "description": "Postgres storage adapter for boardgame.io library", 5 | "homepage": "https://github.com/janKir/bgio-postgres#readme", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/janKir/bgio-postgres.git" 9 | }, 10 | "keywords": [ 11 | "boardgame.io", 12 | "postgres", 13 | "postgresql" 14 | ], 15 | "main": "lib/index.js", 16 | "types": "lib/index.d.ts", 17 | "scripts": { 18 | "build": "tsc", 19 | "prepare": "npm run build", 20 | "typecheck": "tsc --noEmit", 21 | "lint": "eslint \"src/**/*.{js,ts}\"", 22 | "test": "jest --runInBand --config ./test/jest.config.js", 23 | "test:cov": "npm test -- --coverage", 24 | "test:db": "npm test", 25 | "pretest:db": "docker-compose -f test/docker-compose.yml up -d", 26 | "posttest:db": "docker-compose -f test/docker-compose.yml down" 27 | }, 28 | "files": [ 29 | "lib/**/*" 30 | ], 31 | "author": "jankir", 32 | "license": "ISC", 33 | "devDependencies": { 34 | "@golevelup/ts-jest": "^0.3.3", 35 | "@types/jest": "^29.1.2", 36 | "@types/node": "^14.0.11", 37 | "@types/validator": "^13.0.0", 38 | "@typescript-eslint/eslint-plugin": "^3.10.1", 39 | "@typescript-eslint/parser": "^3.10.1", 40 | "boardgame.io": "^0.42.2", 41 | "eslint": "^7.16.0", 42 | "eslint-config-prettier": "^6.11.0", 43 | "eslint-plugin-eslint-comments": "^3.2.0", 44 | "eslint-plugin-import": "^2.22.1", 45 | "eslint-plugin-prettier": "^3.1.3", 46 | "jest": "^29.2.0", 47 | "nodemon": "^2.0.4", 48 | "prettier": "^2.0.5", 49 | "ts-jest": "^29.0.3", 50 | "typescript": "^4.8.4" 51 | }, 52 | "dependencies": { 53 | "dotenv": "^16.0.3", 54 | "pg": "^8.2.1", 55 | "pg-hstore": "^2.3.3", 56 | "sequelize": "^6.3.5" 57 | }, 58 | "peerDependencies": { 59 | "boardgame.io": ">=0.40.0" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/entities/index.ts: -------------------------------------------------------------------------------- 1 | export { Match } from "./match"; 2 | -------------------------------------------------------------------------------- /src/entities/match.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes, Model, ModelAttributes } from "sequelize"; 2 | import { State, LogEntry, Server } from "boardgame.io"; 3 | 4 | export class Match extends Model { 5 | public id!: string; 6 | // metadata 7 | public gameName!: string; 8 | public players!: { [id: number]: Server.PlayerMetadata }; 9 | public setupData!: unknown | undefined; 10 | public gameover!: unknown | undefined; 11 | public nextRoomID!: string | undefined; 12 | public unlisted!: boolean | undefined; 13 | // state 14 | public state!: State; 15 | public initialState!: State; 16 | // log 17 | public log!: LogEntry[]; 18 | 19 | // timestamps! 20 | public readonly createdAt!: Date; 21 | public readonly updatedAt!: Date; 22 | } 23 | 24 | export const matchAttributes: ModelAttributes = { 25 | id: { 26 | type: DataTypes.STRING, 27 | unique: true, 28 | primaryKey: true, 29 | }, 30 | // metadata 31 | gameName: { 32 | type: DataTypes.STRING, 33 | }, 34 | players: { 35 | type: DataTypes.JSON, 36 | }, 37 | setupData: { 38 | type: DataTypes.JSON, 39 | }, 40 | gameover: { 41 | type: DataTypes.JSON, 42 | }, 43 | nextRoomID: { 44 | type: DataTypes.STRING, 45 | }, 46 | unlisted: { 47 | type: DataTypes.BOOLEAN, 48 | }, 49 | // State 50 | state: { 51 | type: DataTypes.JSON, 52 | }, 53 | initialState: { 54 | type: DataTypes.JSON, 55 | }, 56 | // Log 57 | log: { 58 | type: DataTypes.JSON, 59 | }, 60 | }; 61 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./postgres"; 2 | -------------------------------------------------------------------------------- /src/postgres.ts: -------------------------------------------------------------------------------- 1 | import { LogEntry, Server, State, StorageAPI } from "boardgame.io"; 2 | import { Async } from "boardgame.io/internal"; 3 | import { Sequelize, Options, Op } from "sequelize"; 4 | import { Match, matchAttributes } from "./entities/match"; 5 | 6 | export class PostgresStore extends Async { 7 | private _sequelize: Sequelize; 8 | 9 | constructor(uri: string, options?: Options); 10 | constructor(options: Options); 11 | constructor(uriOrOptions: Options | string, extraOptions?: Options) { 12 | super(); 13 | if (typeof uriOrOptions === "string") { 14 | this._sequelize = new Sequelize(uriOrOptions, extraOptions || {}); 15 | } else { 16 | this._sequelize = new Sequelize({ dialect: "postgres", ...uriOrOptions }); 17 | } 18 | 19 | Match.init(matchAttributes, { 20 | sequelize: this._sequelize, 21 | tableName: "Games", 22 | }); 23 | } 24 | 25 | get sequelize(): Sequelize { 26 | return this._sequelize; 27 | } 28 | 29 | /** 30 | * Connect. 31 | */ 32 | async connect(): Promise { 33 | // sync sequelize models with database schema 34 | await this._sequelize.sync(); 35 | } 36 | 37 | /** 38 | * Create a new match. 39 | * 40 | * This might just need to call setState and setMetadata in 41 | * most implementations. 42 | * 43 | * However, it exists as a separate call so that the 44 | * implementation can provision things differently when 45 | * a match is created. For example, it might stow away the 46 | * initial match state in a separate field for easier retrieval. 47 | */ 48 | async createMatch( 49 | id: string, 50 | { 51 | initialState, 52 | metadata: { 53 | gameName, 54 | players, 55 | setupData, 56 | gameover, 57 | nextMatchID, 58 | unlisted, 59 | }, 60 | }: StorageAPI.CreateMatchOpts 61 | ): Promise { 62 | await Match.create({ 63 | id, 64 | gameName, 65 | players, 66 | setupData, 67 | gameover, 68 | nextRoomID: nextMatchID, 69 | unlisted, 70 | initialState, 71 | state: initialState, 72 | log: [], 73 | }); 74 | } 75 | 76 | /** 77 | * Create a new game. 78 | * 79 | * This might just need to call setState and setMetadata in 80 | * most implementations. 81 | * 82 | * However, it exists as a separate call so that the 83 | * implementation can provision things differently when 84 | * a game is created. For example, it might stow away the 85 | * initial game state in a separate field for easier retrieval. 86 | * 87 | * @deprecated Use createMatch instead, if implemented 88 | */ 89 | createGame(matchID: string, opts: StorageAPI.CreateGameOpts): Promise { 90 | return this.createMatch(matchID, opts); 91 | } 92 | 93 | /** 94 | * Update the game state. 95 | * 96 | * If passed a deltalog array, setState should append its contents to the 97 | * existing log for this game. 98 | */ 99 | async setState( 100 | id: string, 101 | state: State, 102 | deltalog?: LogEntry[] 103 | ): Promise { 104 | await this._sequelize.transaction(async (transaction) => { 105 | // 1. get previous state 106 | const match: Match | null = await Match.findByPk(id, { 107 | transaction, 108 | }); 109 | const previousState = match?.state; 110 | // 2. check if given state is newer than previous, otherwise skip 111 | if (!previousState || previousState._stateID < state._stateID) { 112 | await Match.upsert( 113 | { 114 | id, 115 | // 3. set new state 116 | state, 117 | // 4. append deltalog to log if provided 118 | log: [...(match?.log ?? []), ...(deltalog ?? [])], 119 | }, 120 | { transaction } 121 | ); 122 | } 123 | }); 124 | } 125 | 126 | /** 127 | * Update the game metadata. 128 | */ 129 | async setMetadata( 130 | id: string, 131 | { 132 | gameName, 133 | players, 134 | setupData, 135 | gameover, 136 | nextMatchID, 137 | unlisted, 138 | createdAt, 139 | updatedAt, 140 | }: Server.MatchData 141 | ): Promise { 142 | await Match.upsert({ 143 | id, 144 | gameName, 145 | players, 146 | setupData, 147 | gameover, 148 | nextRoomID: nextMatchID, 149 | unlisted, 150 | createdAt: createdAt ? new Date(createdAt) : undefined, 151 | updatedAt: updatedAt ? new Date(updatedAt) : undefined, 152 | }); 153 | } 154 | 155 | /** 156 | * Fetch the game state. 157 | */ 158 | async fetch( 159 | matchID: string, 160 | { state, log, metadata, initialState }: O 161 | ): Promise> { 162 | const result = {} as StorageAPI.FetchFields; 163 | const match: Match | null = await Match.findByPk(matchID); 164 | 165 | if (!match) { 166 | return result; 167 | } 168 | 169 | if (metadata) { 170 | result.metadata = { 171 | gameName: match.gameName, 172 | players: match.players || [], 173 | setupData: match.setupData, 174 | gameover: match.gameover, 175 | nextMatchID: match.nextRoomID, 176 | unlisted: match.unlisted, 177 | createdAt: match.createdAt.getTime(), 178 | updatedAt: match.updatedAt.getTime(), 179 | }; 180 | } 181 | if (initialState) { 182 | result.initialState = match.initialState; 183 | } 184 | if (state) { 185 | result.state = match.state!; 186 | } 187 | if (log) { 188 | result.log = match.log; 189 | } 190 | 191 | return result as StorageAPI.FetchResult; 192 | } 193 | 194 | /** 195 | * Remove the game state. 196 | */ 197 | async wipe(id: string): Promise { 198 | await Match.destroy({ where: { id } }); 199 | } 200 | 201 | /** 202 | * Return all matches. 203 | */ 204 | async listMatches(opts?: StorageAPI.ListMatchesOpts): Promise { 205 | const where = { 206 | [Op.and]: [ 207 | opts?.gameName && { gameName: opts.gameName }, 208 | opts?.where?.isGameover === true && { gameover: { [Op.ne]: null } }, 209 | opts?.where?.isGameover === false && { gameover: { [Op.is]: null } }, 210 | opts?.where?.updatedBefore !== undefined && { 211 | updatedAt: { [Op.lt]: opts.where.updatedBefore }, 212 | }, 213 | opts?.where?.updatedAfter !== undefined && { 214 | updatedAt: { [Op.gt]: opts.where.updatedAfter }, 215 | }, 216 | ], 217 | }; 218 | 219 | const matches: Match[] = await Match.findAll({ 220 | attributes: ["id"], 221 | where, 222 | }); 223 | return matches.map((match) => match.id); 224 | } 225 | 226 | /** 227 | * Return all games. 228 | * 229 | * @deprecated Use listMatches instead, if implemented 230 | */ 231 | listGames(opts?: StorageAPI.ListGamesOpts): Promise { 232 | return this.listMatches(opts); 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /src/sequelize/index.ts: -------------------------------------------------------------------------------- 1 | export * from "sequelize"; 2 | -------------------------------------------------------------------------------- /test/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | db: 5 | image: postgres:12-alpine 6 | ports: 7 | - 5432:5432 8 | environment: 9 | POSTGRES_PASSWORD: postgres 10 | POSTGRES_USER: postgres 11 | POSTGRES_DB: postgres 12 | -------------------------------------------------------------------------------- /test/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | setupFiles: ["dotenv/config"], 6 | }; 7 | -------------------------------------------------------------------------------- /test/mock-data/log-entry.mock.ts: -------------------------------------------------------------------------------- 1 | import { LogEntry } from "boardgame.io"; 2 | 3 | export const logEntry: LogEntry = { 4 | action: { 5 | type: "MAKE_MOVE", 6 | payload: { 7 | type: "MAKE_MOVE", 8 | args: null, 9 | playerID: "101", 10 | }, 11 | }, 12 | _stateID: 1, 13 | turn: 1, 14 | phase: "setup", 15 | }; 16 | -------------------------------------------------------------------------------- /test/mock-data/match.mock.ts: -------------------------------------------------------------------------------- 1 | import { CreationAttributes } from "sequelize"; 2 | import { Match } from "../../src/entities/match"; 3 | import { logEntry } from "./log-entry.mock"; 4 | import { state } from "./state.mock"; 5 | 6 | export const match: CreationAttributes = { 7 | id: "test-id", 8 | initialState: state, 9 | state: state, 10 | gameName: "test-gamename", 11 | players: { 12 | 101: { id: 101 }, 13 | 102: { id: 102 }, 14 | 103: { id: 103 }, 15 | }, 16 | setupData: 2, 17 | gameover: "gameover", 18 | nextRoomID: "nextMatchId", 19 | unlisted: false, 20 | log: [logEntry], 21 | }; 22 | -------------------------------------------------------------------------------- /test/mock-data/state.mock.ts: -------------------------------------------------------------------------------- 1 | import { State } from "boardgame.io"; 2 | 3 | export const state: State = { 4 | ctx: { 5 | numPlayers: 3, 6 | playOrder: ["101", "102", "103"], 7 | playOrderPos: 0, 8 | activePlayers: null, 9 | currentPlayer: "101", 10 | turn: 1, 11 | phase: "setup", 12 | }, 13 | G: 2, 14 | plugins: {}, 15 | _undo: [], 16 | _redo: [], 17 | _stateID: 1, 18 | }; 19 | -------------------------------------------------------------------------------- /test/src/connect.test.ts: -------------------------------------------------------------------------------- 1 | import { TestPostgresStore } from "../test-postgres-store"; 2 | 3 | describe("connect to PostgreSQL database", () => { 4 | let testStore: TestPostgresStore; 5 | 6 | beforeAll(async () => { 7 | testStore = TestPostgresStore.create(); 8 | await testStore.beforeAll(); 9 | }); 10 | 11 | beforeEach(async () => { 12 | await testStore.beforeEach(); 13 | }); 14 | 15 | afterAll(async () => { 16 | await testStore.sequelize.close(); 17 | }); 18 | 19 | it("should create the Games table in the database", async () => { 20 | const sequelize = testStore.sequelize; 21 | 22 | const [tables] = await sequelize.query( 23 | "SELECT * FROM pg_catalog.pg_tables WHERE tablename = 'Games';" 24 | ); 25 | expect(tables).toHaveLength(1); 26 | }); 27 | 28 | it("should create columns in Games table", async () => { 29 | const [columns] = await testStore.sequelize.query( 30 | `SELECT 31 | column_name, 32 | data_type, 33 | case when character_maximum_length is not null 34 | then character_maximum_length 35 | else numeric_precision end as max_length, 36 | is_nullable 37 | from information_schema.columns 38 | where table_name = 'Games';` 39 | ); 40 | expect(columns).toHaveLength(12); 41 | 42 | expect(columns).toContainEqual({ 43 | column_name: "id", 44 | data_type: "character varying", 45 | max_length: 255, 46 | is_nullable: "NO", 47 | }); 48 | 49 | expect(columns).toContainEqual({ 50 | column_name: "createdAt", 51 | data_type: "timestamp with time zone", 52 | max_length: null, 53 | is_nullable: "NO", 54 | }); 55 | 56 | expect(columns).toContainEqual({ 57 | column_name: "updatedAt", 58 | data_type: "timestamp with time zone", 59 | max_length: null, 60 | is_nullable: "NO", 61 | }); 62 | 63 | expect(columns).toContainEqual({ 64 | column_name: "initialState", 65 | data_type: "json", 66 | max_length: null, 67 | is_nullable: "YES", 68 | }); 69 | 70 | expect(columns).toContainEqual({ 71 | column_name: "log", 72 | data_type: "json", 73 | max_length: null, 74 | is_nullable: "YES", 75 | }); 76 | 77 | expect(columns).toContainEqual({ 78 | column_name: "players", 79 | data_type: "json", 80 | max_length: null, 81 | is_nullable: "YES", 82 | }); 83 | 84 | expect(columns).toContainEqual({ 85 | column_name: "setupData", 86 | data_type: "json", 87 | max_length: null, 88 | is_nullable: "YES", 89 | }); 90 | 91 | expect(columns).toContainEqual({ 92 | column_name: "gameover", 93 | data_type: "json", 94 | max_length: null, 95 | is_nullable: "YES", 96 | }); 97 | 98 | expect(columns).toContainEqual({ 99 | column_name: "unlisted", 100 | data_type: "boolean", 101 | max_length: null, 102 | is_nullable: "YES", 103 | }); 104 | 105 | expect(columns).toContainEqual({ 106 | column_name: "state", 107 | data_type: "json", 108 | max_length: null, 109 | is_nullable: "YES", 110 | }); 111 | 112 | expect(columns).toContainEqual({ 113 | column_name: "gameName", 114 | data_type: "character varying", 115 | max_length: 255, 116 | is_nullable: "YES", 117 | }); 118 | 119 | expect(columns).toContainEqual({ 120 | column_name: "nextRoomID", 121 | data_type: "character varying", 122 | max_length: 255, 123 | is_nullable: "YES", 124 | }); 125 | }); 126 | }); 127 | -------------------------------------------------------------------------------- /test/src/constructor.test.ts: -------------------------------------------------------------------------------- 1 | import { PostgresStore } from "../../src/postgres"; 2 | 3 | describe("instantiate new PostgresStore", () => { 4 | it("should create a new instance using a URI", async () => { 5 | const db = new PostgresStore( 6 | `postgresql://${process.env.DB_USER}:${process.env.DB_PASSWORD}@${process.env.DB_HOST}:${process.env.DB_PORT}/${process.env.DB_DATABASE}` 7 | ); 8 | 9 | expect(db).toBeDefined(); 10 | 11 | await db.connect(); 12 | await db.sequelize.close(); 13 | }); 14 | 15 | it("should create a new instance using an options object", async () => { 16 | const db = new PostgresStore({ 17 | database: process.env.DB_DATABASE!, 18 | username: process.env.DB_USER!, 19 | password: process.env.DB_PASSWORD!, 20 | host: process.env.DB_HOST!, 21 | port: Number.parseInt(process.env.DB_PORT!), 22 | }); 23 | 24 | expect(db).toBeDefined(); 25 | 26 | await db.connect(); 27 | await db.sequelize.close(); 28 | }); 29 | 30 | it("should create a new instance but throw on connect() if credentials are wrong", async () => { 31 | const db = new PostgresStore({ 32 | database: "unknown", 33 | username: "nobody", 34 | password: "wrong", 35 | host: "notfound", 36 | port: 1234, 37 | }); 38 | 39 | expect(db).toBeDefined(); 40 | 41 | expect(async () => { 42 | await db.connect(); 43 | }).rejects.toBeDefined(); 44 | 45 | await db.sequelize.close(); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /test/src/create-match.test.ts: -------------------------------------------------------------------------------- 1 | import { match } from "../mock-data/match.mock"; 2 | import { TestPostgresStore } from "../test-postgres-store"; 3 | 4 | describe("create new match", () => { 5 | let testStore: TestPostgresStore; 6 | 7 | beforeAll(async () => { 8 | testStore = TestPostgresStore.create(); 9 | await testStore.beforeAll(); 10 | }); 11 | 12 | beforeEach(async () => { 13 | await testStore.beforeEach(); 14 | }); 15 | 16 | afterAll(async () => { 17 | await testStore.afterAll(); 18 | }); 19 | 20 | it("should create a new database entry", async () => { 21 | await testStore.db.createMatch(match.id!, { 22 | initialState: match.initialState!, 23 | metadata: { 24 | gameName: match.gameName!, 25 | players: match.players!, 26 | setupData: match.setupData, 27 | gameover: match.gameover, 28 | nextMatchID: match.nextRoomID, 29 | unlisted: match.unlisted, 30 | createdAt: 0, 31 | updatedAt: 0, 32 | }, 33 | }); 34 | match.state = match.initialState; 35 | 36 | const [results] = await testStore.sequelize.query( 37 | "SELECT * FROM \"Games\" WHERE id = 'test-id'" 38 | ); 39 | expect(results).toHaveLength(1); 40 | 41 | const result = results[0]; 42 | expect(result).toEqual({ 43 | ...match, 44 | log: [], 45 | createdAt: expect.any(Date), 46 | updatedAt: expect.any(Date), 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /test/src/fetch.test.ts: -------------------------------------------------------------------------------- 1 | import { Match } from "../../src/entities/match"; 2 | import { match } from "../mock-data/match.mock"; 3 | import { TestPostgresStore } from "../test-postgres-store"; 4 | 5 | describe("fetch", () => { 6 | let testStore: TestPostgresStore; 7 | 8 | beforeAll(async () => { 9 | testStore = TestPostgresStore.create(); 10 | await testStore.beforeAll(); 11 | }); 12 | 13 | beforeEach(async () => { 14 | await testStore.beforeEach(); 15 | await Match.create(match); 16 | }); 17 | 18 | afterAll(async () => { 19 | await testStore.afterAll(); 20 | }); 21 | 22 | it("should return empty object if match is not found", async () => { 23 | const result = await testStore.db.fetch("non-existent-id", { state: true }); 24 | 25 | expect(result).toEqual({}); 26 | }); 27 | 28 | it("should return empty object if all flags are falsy", async () => { 29 | const result = await testStore.db.fetch(match.id, {}); 30 | 31 | expect(result).toEqual({}); 32 | }); 33 | 34 | it("should return metadata key in object if flag is true", async () => { 35 | const result = await testStore.db.fetch(match.id, { metadata: true }); 36 | 37 | expect(result).toEqual({ 38 | metadata: { 39 | gameName: match.gameName, 40 | players: match.players, 41 | setupData: match.setupData, 42 | gameover: match.gameover, 43 | nextMatchID: match.nextRoomID, 44 | unlisted: match.unlisted, 45 | createdAt: expect.any(Number), 46 | updatedAt: expect.any(Number), 47 | }, 48 | }); 49 | }); 50 | 51 | it("should return initialState key in object if flag is true", async () => { 52 | const result = await testStore.db.fetch(match.id, { initialState: true }); 53 | 54 | expect(result).toEqual({ 55 | initialState: match.initialState, 56 | }); 57 | }); 58 | 59 | it("should return state key in object if flag is true", async () => { 60 | const result = await testStore.db.fetch(match.id, { state: true }); 61 | 62 | expect(result).toEqual({ 63 | state: match.state, 64 | }); 65 | }); 66 | 67 | it("should return log key in object if flag is true", async () => { 68 | const result = await testStore.db.fetch(match.id, { log: true }); 69 | 70 | expect(result).toEqual({ 71 | log: match.log, 72 | }); 73 | }); 74 | 75 | it("should return all keys in object if all flags are true", async () => { 76 | const result = await testStore.db.fetch(match.id, { 77 | metadata: true, 78 | initialState: true, 79 | state: true, 80 | log: true, 81 | }); 82 | 83 | expect(result).toEqual({ 84 | metadata: { 85 | gameName: match.gameName, 86 | players: match.players, 87 | setupData: match.setupData, 88 | gameover: match.gameover, 89 | nextMatchID: match.nextRoomID, 90 | unlisted: match.unlisted, 91 | createdAt: expect.any(Number), 92 | updatedAt: expect.any(Number), 93 | }, 94 | initialState: match.initialState, 95 | state: match.state, 96 | log: match.log, 97 | }); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /test/src/list-matches.test.ts: -------------------------------------------------------------------------------- 1 | import { Match } from "../../src/entities/match"; 2 | import { match } from "../mock-data/match.mock"; 3 | import { TestPostgresStore } from "../test-postgres-store"; 4 | 5 | describe("listMatches", () => { 6 | let testStore: TestPostgresStore; 7 | 8 | beforeAll(async () => { 9 | jest.useFakeTimers(); 10 | 11 | testStore = TestPostgresStore.create(); 12 | await testStore.beforeAll(); 13 | }); 14 | 15 | beforeEach(async () => { 16 | await testStore.beforeEach(); 17 | 18 | jest.setSystemTime(1666000000000); 19 | await Match.create({ 20 | ...match, 21 | id: "test-id-1", 22 | gameName: "test-game-1", 23 | gameover: null, 24 | }); 25 | 26 | jest.setSystemTime(1667000000000); 27 | await Match.create({ 28 | ...match, 29 | id: "test-id-2", 30 | gameName: "test-game-1", 31 | gameover: "test-gameover", 32 | }); 33 | 34 | jest.setSystemTime(1668000000000); 35 | await Match.create({ 36 | ...match, 37 | id: "test-id-3", 38 | gameName: "test-game-1", 39 | gameover: "test-gameover", 40 | }); 41 | 42 | jest.setSystemTime(1666000000000); 43 | await Match.create({ 44 | ...match, 45 | id: "test-id-4", 46 | gameName: "test-game-2", 47 | gameover: null, 48 | }); 49 | 50 | jest.setSystemTime(1667000000000); 51 | await Match.create({ 52 | ...match, 53 | id: "test-id-5", 54 | gameName: "test-game-2", 55 | gameover: "test-gameover", 56 | }); 57 | 58 | jest.setSystemTime(1668000000000); 59 | await Match.create({ 60 | ...match, 61 | id: "test-id-6", 62 | gameName: "test-game-2", 63 | gameover: null, 64 | }); 65 | }); 66 | 67 | afterAll(async () => { 68 | await testStore.afterAll(); 69 | 70 | jest.useRealTimers(); 71 | }); 72 | 73 | it("should return all matches if no filter is provided", async () => { 74 | const result = await testStore.db.listMatches(); 75 | 76 | expect(result).toHaveLength(6); 77 | expect(result).toContain("test-id-1"); 78 | expect(result).toContain("test-id-2"); 79 | expect(result).toContain("test-id-3"); 80 | expect(result).toContain("test-id-4"); 81 | expect(result).toContain("test-id-5"); 82 | expect(result).toContain("test-id-6"); 83 | }); 84 | 85 | it("should only return matches with provided gamename", async () => { 86 | const result = await testStore.db.listMatches({ gameName: "test-game-1" }); 87 | 88 | expect(result).toHaveLength(3); 89 | expect(result).toContain("test-id-1"); 90 | expect(result).toContain("test-id-2"); 91 | expect(result).toContain("test-id-3"); 92 | }); 93 | 94 | it("should only return finished matches if isGameover flag is true", async () => { 95 | const result = await testStore.db.listMatches({ 96 | where: { 97 | isGameover: true, 98 | }, 99 | }); 100 | 101 | expect(result).toHaveLength(3); 102 | expect(result).toContain("test-id-2"); 103 | expect(result).toContain("test-id-3"); 104 | expect(result).toContain("test-id-5"); 105 | }); 106 | 107 | it("should only return unfinished matches if isGameover flag is false", async () => { 108 | const result = await testStore.db.listMatches({ 109 | where: { 110 | isGameover: false, 111 | }, 112 | }); 113 | 114 | expect(result).toHaveLength(3); 115 | expect(result).toContain("test-id-1"); 116 | expect(result).toContain("test-id-4"); 117 | expect(result).toContain("test-id-6"); 118 | }); 119 | 120 | it("should only return matches last updated before timestamp provided in updatedBefore", async () => { 121 | const result = await testStore.db.listMatches({ 122 | where: { 123 | updatedBefore: 1667000000001, 124 | }, 125 | }); 126 | 127 | expect(result).toHaveLength(4); 128 | expect(result).toContain("test-id-1"); 129 | expect(result).toContain("test-id-2"); 130 | expect(result).toContain("test-id-4"); 131 | expect(result).toContain("test-id-5"); 132 | }); 133 | 134 | it("should only return matches last updated after timestamp provided in updatedAfter", async () => { 135 | const result = await testStore.db.listMatches({ 136 | where: { 137 | updatedAfter: 1666000000001, 138 | }, 139 | }); 140 | 141 | expect(result).toHaveLength(4); 142 | expect(result).toContain("test-id-2"); 143 | expect(result).toContain("test-id-3"); 144 | expect(result).toContain("test-id-5"); 145 | expect(result).toContain("test-id-6"); 146 | }); 147 | 148 | it("should only return matches that fit all filter criteria", async () => { 149 | const result = await testStore.db.listMatches({ 150 | gameName: "test-game-1", 151 | where: { 152 | isGameover: true, 153 | updatedBefore: 1667000000001, 154 | updatedAfter: 1666000000001, 155 | }, 156 | }); 157 | 158 | expect(result).toHaveLength(1); 159 | expect(result).toContain("test-id-2"); 160 | 161 | const emptyResult = await testStore.db.listMatches({ 162 | gameName: "test-game-1", 163 | where: { 164 | isGameover: false, 165 | updatedBefore: 1667000000001, 166 | updatedAfter: 1666000000001, 167 | }, 168 | }); 169 | 170 | expect(emptyResult).toHaveLength(0); 171 | }); 172 | }); 173 | -------------------------------------------------------------------------------- /test/src/set-metadata.test.ts: -------------------------------------------------------------------------------- 1 | import { Match } from "../../src/entities/match"; 2 | import { TestPostgresStore } from "../test-postgres-store"; 3 | import { match } from "../mock-data/match.mock"; 4 | import { Server } from "boardgame.io"; 5 | 6 | describe("setMetadata", () => { 7 | let testStore: TestPostgresStore; 8 | 9 | beforeAll(async () => { 10 | testStore = TestPostgresStore.create(); 11 | await testStore.beforeAll(); 12 | }); 13 | 14 | beforeEach(async () => { 15 | await testStore.beforeEach(); 16 | }); 17 | 18 | afterAll(async () => { 19 | await testStore.afterAll(); 20 | }); 21 | 22 | it("should update the metadata of the Match with the given ID", async () => { 23 | await Match.create(match); 24 | 25 | const nextMetadata: Server.MatchData = { 26 | gameName: "test-game2", 27 | players: { 28 | "0": { id: 0, name: "Player 1" }, 29 | "1": { id: 1, name: "Player 2" }, 30 | }, 31 | setupData: 3, 32 | gameover: "gameover2", 33 | nextMatchID: "next-match-id2", 34 | unlisted: true, 35 | createdAt: 2, 36 | updatedAt: 2, 37 | }; 38 | 39 | await testStore.db.setMetadata(match.id!, nextMetadata); 40 | 41 | const [results] = await testStore.sequelize.query( 42 | `SELECT * FROM "Games" WHERE id = '${match.id}'` 43 | ); 44 | expect(results).toHaveLength(1); 45 | 46 | const result = results[0]; 47 | const { nextMatchID: nextRoomID, ...metadata } = nextMetadata; 48 | expect(result).toEqual({ 49 | ...match, 50 | ...metadata, 51 | nextRoomID, 52 | createdAt: new Date(nextMetadata.createdAt), 53 | updatedAt: new Date(nextMetadata.updatedAt), 54 | }); 55 | }); 56 | 57 | it("should create a new Match if none is found with given ID", async () => { 58 | const nextMetadata: Server.MatchData = { 59 | gameName: "test-game2", 60 | players: { 61 | "0": { id: 0, name: "Player 1" }, 62 | "1": { id: 1, name: "Player 2" }, 63 | }, 64 | setupData: 3, 65 | gameover: "gameover2", 66 | nextMatchID: "next-match-id2", 67 | unlisted: true, 68 | createdAt: 2, 69 | updatedAt: 2, 70 | }; 71 | 72 | await testStore.db.setMetadata(match.id!, nextMetadata); 73 | 74 | const [results] = await testStore.sequelize.query( 75 | `SELECT * FROM "Games" WHERE id = '${match.id}'` 76 | ); 77 | expect(results).toHaveLength(1); 78 | 79 | const result = results[0]; 80 | const { nextMatchID: nextRoomID, ...metadata } = nextMetadata; 81 | expect(result).toEqual({ 82 | id: match.id, 83 | initialState: null, 84 | state: null, 85 | log: null, 86 | ...metadata, 87 | nextRoomID, 88 | createdAt: new Date(nextMetadata.createdAt), 89 | updatedAt: new Date(nextMetadata.updatedAt), 90 | }); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /test/src/set-state.test.ts: -------------------------------------------------------------------------------- 1 | import { Match } from "../../src/entities/match"; 2 | import { TestPostgresStore } from "../test-postgres-store"; 3 | import { match } from "../mock-data/match.mock"; 4 | import { LogEntry, State } from "boardgame.io"; 5 | 6 | describe("setState", () => { 7 | let testStore: TestPostgresStore; 8 | 9 | beforeAll(async () => { 10 | testStore = TestPostgresStore.create(); 11 | await testStore.beforeAll(); 12 | }); 13 | 14 | beforeEach(async () => { 15 | await testStore.beforeEach(); 16 | }); 17 | 18 | afterAll(async () => { 19 | await testStore.afterAll(); 20 | }); 21 | 22 | it("should update the state of the Match with the given ID", async () => { 23 | await Match.create(match); 24 | 25 | const nextState: State = { 26 | ...match.state, 27 | ctx: { ...match.state.ctx, currentPlayer: "102", turn: 2 }, 28 | _stateID: 2, 29 | }; 30 | 31 | await testStore.db.setState(match.id!, nextState); 32 | 33 | const [results] = await testStore.sequelize.query( 34 | `SELECT * FROM "Games" WHERE id = '${match.id}'` 35 | ); 36 | expect(results).toHaveLength(1); 37 | 38 | const result = results[0]; 39 | expect(result).toEqual({ 40 | ...match, 41 | state: nextState, 42 | createdAt: expect.any(Date), 43 | updatedAt: expect.any(Date), 44 | }); 45 | }); 46 | 47 | it("should append the deltalogs to the Matchs logs", async () => { 48 | await Match.create(match); 49 | 50 | const nextState: State = { 51 | ...match.state, 52 | ctx: { ...match.state.ctx, currentPlayer: "102", turn: 2 }, 53 | _stateID: 2, 54 | }; 55 | 56 | const deltaLogs: LogEntry[] = [ 57 | { 58 | action: { 59 | type: "MAKE_MOVE", 60 | payload: { 61 | type: "MAKE_MOVE", 62 | args: null, 63 | playerID: "102", 64 | }, 65 | }, 66 | _stateID: 2, 67 | turn: 2, 68 | phase: "setup", 69 | }, 70 | ]; 71 | 72 | await testStore.db.setState(match.id!, nextState, deltaLogs); 73 | 74 | const [results] = await testStore.sequelize.query( 75 | `SELECT * FROM "Games" WHERE id = '${match.id}'` 76 | ); 77 | expect(results).toHaveLength(1); 78 | 79 | const result = results[0]; 80 | expect(result).toEqual({ 81 | ...match, 82 | state: nextState, 83 | log: [...match.log, ...deltaLogs], 84 | createdAt: expect.any(Date), 85 | updatedAt: expect.any(Date), 86 | }); 87 | }); 88 | 89 | it("should not make any changes if given state is outdated", async () => { 90 | await Match.create(match); 91 | 92 | const nextState: State = { 93 | ...match.state, 94 | ctx: { ...match.state.ctx, currentPlayer: "103", turn: 0 }, 95 | _stateID: 0, 96 | }; 97 | 98 | await testStore.db.setState(match.id!, nextState); 99 | 100 | const [results] = await testStore.sequelize.query( 101 | `SELECT * FROM "Games" WHERE id = '${match.id}'` 102 | ); 103 | expect(results).toHaveLength(1); 104 | 105 | const result = results[0]; 106 | expect(result).toEqual({ 107 | ...match, 108 | state: match.state, 109 | createdAt: expect.any(Date), 110 | updatedAt: expect.any(Date), 111 | }); 112 | }); 113 | 114 | it("should create a new Match if none is found with given ID", async () => { 115 | await testStore.db.setState(match.id!, match.state); 116 | 117 | const [results] = await testStore.sequelize.query( 118 | `SELECT * FROM "Games" WHERE id = '${match.id}'` 119 | ); 120 | expect(results).toHaveLength(1); 121 | 122 | const result = results[0]; 123 | expect(result).toEqual({ 124 | ...match, 125 | state: match.state, 126 | initialState: null, 127 | gameName: null, 128 | players: null, 129 | setupData: null, 130 | gameover: null, 131 | nextRoomID: null, 132 | unlisted: null, 133 | log: [], 134 | createdAt: expect.any(Date), 135 | updatedAt: expect.any(Date), 136 | }); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /test/src/wipe.test.ts: -------------------------------------------------------------------------------- 1 | import { Match } from "../../src/entities/match"; 2 | import { match } from "../mock-data/match.mock"; 3 | import { TestPostgresStore } from "../test-postgres-store"; 4 | 5 | describe("wipe", () => { 6 | let testStore: TestPostgresStore; 7 | 8 | beforeAll(async () => { 9 | testStore = TestPostgresStore.create(); 10 | await testStore.beforeAll(); 11 | }); 12 | 13 | beforeEach(async () => { 14 | await testStore.beforeEach(); 15 | await Match.create(match); 16 | }); 17 | 18 | afterAll(async () => { 19 | await testStore.afterAll(); 20 | }); 21 | 22 | it("should delete the match with the given ID", async () => { 23 | await testStore.db.wipe(match.id!); 24 | 25 | const [results] = await testStore.sequelize.query( 26 | "SELECT * FROM \"Games\" WHERE id = 'test-id'" 27 | ); 28 | expect(results).toHaveLength(0); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /test/test-postgres-store.ts: -------------------------------------------------------------------------------- 1 | import { Sequelize } from "sequelize/types"; 2 | import { PostgresStore } from "../src/postgres"; 3 | 4 | export class TestPostgresStore { 5 | constructor(private postgresStore: PostgresStore) {} 6 | 7 | static create(): TestPostgresStore { 8 | return new TestPostgresStore( 9 | new PostgresStore({ 10 | database: process.env.DB_DATABASE!, 11 | username: process.env.DB_USER!, 12 | password: process.env.DB_PASSWORD!, 13 | host: process.env.DB_HOST!, 14 | port: Number.parseInt(process.env.DB_PORT!), 15 | }) 16 | ); 17 | } 18 | 19 | get db(): PostgresStore { 20 | return this.postgresStore; 21 | } 22 | 23 | get sequelize(): Sequelize { 24 | return this.postgresStore.sequelize; 25 | } 26 | 27 | async beforeAll(): Promise { 28 | await this.postgresStore.connect(); 29 | } 30 | 31 | async beforeEach(): Promise { 32 | await this.postgresStore.sequelize.query('DELETE FROM "Games"'); 33 | } 34 | 35 | async afterAll(): Promise { 36 | await this.postgresStore.sequelize.close(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | /* Basic Options */ 5 | // "incremental": true, /* Enable incremental compilation */ 6 | "target": "es2018", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 7 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 8 | "lib": ["es2018"], /* Specify library files to be included in the compilation. */ 9 | // "allowJs": true, /* Allow javascript files to be compiled. */ 10 | // "checkJs": true, /* Report errors in .js files. */ 11 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 12 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 13 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 14 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 15 | // "outFile": "./", /* Concatenate and emit output to single file. */ 16 | "outDir": "lib", /* Redirect output structure to the directory. */ 17 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 18 | // "composite": true, /* Enable project compilation */ 19 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 20 | "removeComments": true, /* Do not emit comments to output. */ 21 | // "noEmit": true, /* Do not emit outputs. */ 22 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 23 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 24 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 25 | /* Strict Type-Checking Options */ 26 | "strict": true, /* Enable all strict type-checking options. */ 27 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 28 | // "strictNullChecks": true, /* Enable strict null checks. */ 29 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 30 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 31 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 32 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 33 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 34 | /* Additional Checks */ 35 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 36 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 37 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 38 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 39 | /* Module Resolution Options */ 40 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 41 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 42 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 43 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 44 | // "typeRoots": [], /* List of folders to include type definitions from. */ 45 | // "types": [], /* Type declaration files to be included in compilation. */ 46 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 47 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 48 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 49 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 50 | /* Source Map Options */ 51 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 52 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 53 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 54 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 55 | /* Experimental Options */ 56 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 57 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 58 | /* Advanced Options */ 59 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 60 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 61 | }, 62 | "include": [ 63 | "src/**/*", 64 | ], 65 | "exclude": [".git", "node_modules"] 66 | } --------------------------------------------------------------------------------