From 06c1a89015b14f83dc81a41b1c8d8f379ee92df9 Mon Sep 17 00:00:00 2001 From: sanghoang Date: Fri, 17 Apr 2026 14:48:42 +0700 Subject: [PATCH] finish challenge --- .gitignore | 15 + README copy.md | 24 + package-lock.json | 2936 +++++++++++++++++ package.json | 26 + src/{problem4 => problem2}/.keep | 0 src/problem2/index.html | 27 - src/problem2/script.js | 0 src/problem2/style.css | 8 - src/problem4/README.md | 42 + src/problem4/run-tests.ts | 34 + src/problem4/sum_to_n.ts | 50 + src/problem5/.env.example | 3 + src/problem5/.keep | 0 src/problem5/README.md | 583 ++++ src/problem5/docker-compose.yml | 22 + .../docker/init/01-create-test-db.sql | 1 + src/problem5/src/app.ts | 16 + src/problem5/src/config/env.ts | 23 + .../src/controllers/resource.controller.ts | 64 + src/problem5/src/db/data-source.ts | 14 + src/problem5/src/entities/resource.entity.ts | 33 + src/problem5/src/middleware/async-handler.ts | 9 + src/problem5/src/middleware/error-handler.ts | 17 + .../src/repositories/resource.repository.ts | 44 + src/problem5/src/routes/resource.routes.ts | 19 + src/problem5/src/server.ts | 21 + src/problem5/src/services/resource.service.ts | 125 + src/problem5/src/types/resource.ts | 18 + src/problem5/src/utils/http-error.ts | 10 + src/problem5/src/utils/sleep.ts | 5 + .../src/validators/resource.validator.ts | 134 + .../integration/resource.integration.test.ts | 181 + src/problem6/ARCHITECTURE.md | 1128 +++++++ src/problem6/IMPROVEMENTS.md | 628 ++++ src/problem6/README.md | 444 +++ src/problem6/schema.sql | 133 + tsconfig.json | 16 + 37 files changed, 6818 insertions(+), 35 deletions(-) create mode 100644 .gitignore create mode 100644 README copy.md create mode 100644 package-lock.json create mode 100644 package.json rename src/{problem4 => problem2}/.keep (100%) delete mode 100644 src/problem2/index.html delete mode 100644 src/problem2/script.js delete mode 100644 src/problem2/style.css create mode 100644 src/problem4/README.md create mode 100644 src/problem4/run-tests.ts create mode 100644 src/problem4/sum_to_n.ts create mode 100644 src/problem5/.env.example delete mode 100644 src/problem5/.keep create mode 100644 src/problem5/README.md create mode 100644 src/problem5/docker-compose.yml create mode 100644 src/problem5/docker/init/01-create-test-db.sql create mode 100644 src/problem5/src/app.ts create mode 100644 src/problem5/src/config/env.ts create mode 100644 src/problem5/src/controllers/resource.controller.ts create mode 100644 src/problem5/src/db/data-source.ts create mode 100644 src/problem5/src/entities/resource.entity.ts create mode 100644 src/problem5/src/middleware/async-handler.ts create mode 100644 src/problem5/src/middleware/error-handler.ts create mode 100644 src/problem5/src/repositories/resource.repository.ts create mode 100644 src/problem5/src/routes/resource.routes.ts create mode 100644 src/problem5/src/server.ts create mode 100644 src/problem5/src/services/resource.service.ts create mode 100644 src/problem5/src/types/resource.ts create mode 100644 src/problem5/src/utils/http-error.ts create mode 100644 src/problem5/src/utils/sleep.ts create mode 100644 src/problem5/src/validators/resource.validator.ts create mode 100644 src/problem5/tests/integration/resource.integration.test.ts create mode 100644 src/problem6/ARCHITECTURE.md create mode 100644 src/problem6/IMPROVEMENTS.md create mode 100644 src/problem6/README.md create mode 100644 src/problem6/schema.sql create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..8f77410605 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +node_modules/ +dist/ +build/ +coverage/ +*.tsbuildinfo + +.env +.env.local +.env.*.local + +.DS_Store +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/README copy.md b/README copy.md new file mode 100644 index 0000000000..2bf943dc27 --- /dev/null +++ b/README copy.md @@ -0,0 +1,24 @@ +# My Challenge + +This workspace contains my own solutions for: + +- `problem4`: three TypeScript implementations of sum-to-n +- `problem5`: a TypeScript Express + PostgreSQL CRUD API with a concurrency-safe increment endpoint using TypeORM pessimistic locking +- `problem6`: an implementation-ready architecture package for a real-time scoreboard using PostgreSQL, transactional outbox, CDC/Kafka, Redis projections, and live updates + +## Scripts + +From this directory: + +```bash +npm install +npm run test:problem4 +npm run dev:problem5 +npm run test:problem5 +``` + +## Notes + +- Problem 5 expects PostgreSQL for both development and integration tests. +- See `src/problem5/README.md` for database configuration and the locking test design. +- Problem 6 is a documentation and system-design deliverable under `src/problem6/`. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000..f0c82d452c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2936 @@ +{ + "name": "my-challenge", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "my-challenge", + "version": "1.0.0", + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.2", + "pg": "^8.12.0", + "reflect-metadata": "^0.2.2", + "typeorm": "^0.3.24" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/node": "^20.14.11", + "ts-node": "^10.9.2", + "ts-node-dev": "^2.0.0", + "typescript": "^5.6.3" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://npm.vexere.net/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://npm.vexere.net/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://npm.vexere.net/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://npm.vexere.net/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://npm.vexere.net/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://npm.vexere.net/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://npm.vexere.net/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://npm.vexere.net/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://npm.vexere.net/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://npm.vexere.net/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://npm.vexere.net/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://npm.vexere.net/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@sqltools/formatter": { + "version": "1.2.5", + "resolved": "https://npm.vexere.net/@sqltools/formatter/-/formatter-1.2.5.tgz", + "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==", + "license": "MIT" + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://npm.vexere.net/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://npm.vexere.net/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://npm.vexere.net/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://npm.vexere.net/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://npm.vexere.net/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://npm.vexere.net/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://npm.vexere.net/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://npm.vexere.net/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://npm.vexere.net/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://npm.vexere.net/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://npm.vexere.net/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://npm.vexere.net/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://npm.vexere.net/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://npm.vexere.net/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://npm.vexere.net/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://npm.vexere.net/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://npm.vexere.net/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/strip-bom": { + "version": "3.0.0", + "resolved": "https://npm.vexere.net/@types/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-FKjsOVbC6B7bdSB5CuzyHCkK69I=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/strip-json-comments": { + "version": "0.0.30", + "resolved": "https://npm.vexere.net/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz", + "integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://npm.vexere.net/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://npm.vexere.net/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "devOptional": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://npm.vexere.net/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://npm.vexere.net/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://npm.vexere.net/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "4.2.0", + "resolved": "https://npm.vexere.net/ansis/-/ansis-4.2.0.tgz", + "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://npm.vexere.net/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/app-root-path": { + "version": "3.1.0", + "resolved": "https://npm.vexere.net/app-root-path/-/app-root-path-3.1.0.tgz", + "integrity": "sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://npm.vexere.net/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://npm.vexere.net/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=", + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://npm.vexere.net/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/async-generator-function": { + "version": "1.0.0", + "resolved": "https://npm.vexere.net/async-generator-function/-/async-generator-function-1.0.0.tgz", + "integrity": "sha512-+NAXNqgCrB95ya4Sr66i1CL2hqLVckAk7xwRYWdcm39/ELQ6YNn1aw5r0bdQtqNZgQpEWzc5yc/igXc7aL5SLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://npm.vexere.net/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://npm.vexere.net/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://npm.vexere.net/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://npm.vexere.net/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://npm.vexere.net/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://npm.vexere.net/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://npm.vexere.net/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://npm.vexere.net/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://npm.vexere.net/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://npm.vexere.net/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://npm.vexere.net/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://npm.vexere.net/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://npm.vexere.net/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://npm.vexere.net/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://npm.vexere.net/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://npm.vexere.net/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://npm.vexere.net/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://npm.vexere.net/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://npm.vexere.net/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://npm.vexere.net/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://npm.vexere.net/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://npm.vexere.net/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://npm.vexere.net/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://npm.vexere.net/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://npm.vexere.net/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://npm.vexere.net/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://npm.vexere.net/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://npm.vexere.net/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://npm.vexere.net/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://npm.vexere.net/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://npm.vexere.net/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://npm.vexere.net/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://npm.vexere.net/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://npm.vexere.net/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/dynamic-dedupe": { + "version": "0.3.0", + "resolved": "https://npm.vexere.net/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz", + "integrity": "sha1-BuRMIj9eTpTXjvnbI6ZRXOL5YqE=", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://npm.vexere.net/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://npm.vexere.net/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://npm.vexere.net/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://npm.vexere.net/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://npm.vexere.net/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://npm.vexere.net/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://npm.vexere.net/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://npm.vexere.net/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://npm.vexere.net/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://npm.vexere.net/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://npm.vexere.net/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://npm.vexere.net/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://npm.vexere.net/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://npm.vexere.net/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://npm.vexere.net/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://npm.vexere.net/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://npm.vexere.net/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://npm.vexere.net/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://npm.vexere.net/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://npm.vexere.net/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://npm.vexere.net/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://npm.vexere.net/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.1", + "resolved": "https://npm.vexere.net/get-intrinsic/-/get-intrinsic-1.3.1.tgz", + "integrity": "sha512-fk1ZVEeOX9hVZ6QzoBNEC55+Ucqg4sTVwrVuigZhuRPESVFpMyXnd3sbXvPOwp7Y9riVyANiqhEuRF0G1aVSeQ==", + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "async-generator-function": "^1.0.0", + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://npm.vexere.net/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://npm.vexere.net/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://npm.vexere.net/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://npm.vexere.net/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://npm.vexere.net/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://npm.vexere.net/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://npm.vexere.net/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://npm.vexere.net/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://npm.vexere.net/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://npm.vexere.net/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://npm.vexere.net/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://npm.vexere.net/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://npm.vexere.net/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://npm.vexere.net/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://npm.vexere.net/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://npm.vexere.net/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://npm.vexere.net/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://npm.vexere.net/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://npm.vexere.net/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://npm.vexere.net/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://npm.vexere.net/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://npm.vexere.net/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://npm.vexere.net/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://npm.vexere.net/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://npm.vexere.net/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://npm.vexere.net/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://npm.vexere.net/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://npm.vexere.net/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://npm.vexere.net/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://npm.vexere.net/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://npm.vexere.net/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://npm.vexere.net/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://npm.vexere.net/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://npm.vexere.net/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://npm.vexere.net/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://npm.vexere.net/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://npm.vexere.net/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://npm.vexere.net/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://npm.vexere.net/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://npm.vexere.net/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://npm.vexere.net/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://npm.vexere.net/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://npm.vexere.net/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://npm.vexere.net/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://npm.vexere.net/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://npm.vexere.net/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://npm.vexere.net/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://npm.vexere.net/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://npm.vexere.net/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://npm.vexere.net/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://npm.vexere.net/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://npm.vexere.net/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://npm.vexere.net/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://npm.vexere.net/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://npm.vexere.net/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://npm.vexere.net/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://npm.vexere.net/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://npm.vexere.net/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://npm.vexere.net/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://npm.vexere.net/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://npm.vexere.net/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://npm.vexere.net/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://npm.vexere.net/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://npm.vexere.net/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://npm.vexere.net/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://npm.vexere.net/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://npm.vexere.net/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://npm.vexere.net/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://npm.vexere.net/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://npm.vexere.net/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://npm.vexere.net/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://npm.vexere.net/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://npm.vexere.net/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://npm.vexere.net/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://npm.vexere.net/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://npm.vexere.net/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://npm.vexere.net/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://npm.vexere.net/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://npm.vexere.net/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://npm.vexere.net/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://npm.vexere.net/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://npm.vexere.net/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://npm.vexere.net/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://npm.vexere.net/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://npm.vexere.net/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://npm.vexere.net/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://npm.vexere.net/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://npm.vexere.net/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://npm.vexere.net/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://npm.vexere.net/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://npm.vexere.net/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://npm.vexere.net/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://npm.vexere.net/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sql-highlight": { + "version": "6.1.0", + "resolved": "https://npm.vexere.net/sql-highlight/-/sql-highlight-6.1.0.tgz", + "integrity": "sha512-ed7OK4e9ywpE7pgRMkMQmZDPKSVdm0oX5IEtZiKnFucSF0zu6c80GZBe38UqHuVhTWJ9xsKgSMjCG2bml86KvA==", + "funding": [ + "https://github.com/scriptcoded/sql-highlight?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/scriptcoded" + } + ], + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://npm.vexere.net/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://npm.vexere.net/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://npm.vexere.net/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://npm.vexere.net/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://npm.vexere.net/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://npm.vexere.net/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://npm.vexere.net/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://npm.vexere.net/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://npm.vexere.net/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://npm.vexere.net/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://npm.vexere.net/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://npm.vexere.net/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://npm.vexere.net/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node-dev": { + "version": "2.0.0", + "resolved": "https://npm.vexere.net/ts-node-dev/-/ts-node-dev-2.0.0.tgz", + "integrity": "sha512-ywMrhCfH6M75yftYvrvNarLEY+SUXtUvU8/0Z6llrHQVBx12GiFk5sStF8UdfE/yfzk9IAq7O5EEbTQsxlBI8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.1", + "dynamic-dedupe": "^0.3.0", + "minimist": "^1.2.6", + "mkdirp": "^1.0.4", + "resolve": "^1.0.0", + "rimraf": "^2.6.1", + "source-map-support": "^0.5.12", + "tree-kill": "^1.2.2", + "ts-node": "^10.4.0", + "tsconfig": "^7.0.0" + }, + "bin": { + "ts-node-dev": "lib/bin.js", + "tsnd": "lib/bin.js" + }, + "engines": { + "node": ">=0.8.0" + }, + "peerDependencies": { + "node-notifier": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/tsconfig": { + "version": "7.0.0", + "resolved": "https://npm.vexere.net/tsconfig/-/tsconfig-7.0.0.tgz", + "integrity": "sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/strip-bom": "^3.0.0", + "@types/strip-json-comments": "0.0.30", + "strip-bom": "^3.0.0", + "strip-json-comments": "^2.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://npm.vexere.net/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://npm.vexere.net/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://npm.vexere.net/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typeorm": { + "version": "0.3.28", + "resolved": "https://npm.vexere.net/typeorm/-/typeorm-0.3.28.tgz", + "integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==", + "license": "MIT", + "dependencies": { + "@sqltools/formatter": "^1.2.5", + "ansis": "^4.2.0", + "app-root-path": "^3.1.0", + "buffer": "^6.0.3", + "dayjs": "^1.11.19", + "debug": "^4.4.3", + "dedent": "^1.7.0", + "dotenv": "^16.6.1", + "glob": "^10.5.0", + "reflect-metadata": "^0.2.2", + "sha.js": "^2.4.12", + "sql-highlight": "^6.1.0", + "tslib": "^2.8.1", + "uuid": "^11.1.0", + "yargs": "^17.7.2" + }, + "bin": { + "typeorm": "cli.js", + "typeorm-ts-node-commonjs": "cli-ts-node-commonjs.js", + "typeorm-ts-node-esm": "cli-ts-node-esm.js" + }, + "engines": { + "node": ">=16.13.0" + }, + "funding": { + "url": "https://opencollective.com/typeorm" + }, + "peerDependencies": { + "@google-cloud/spanner": "^5.18.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "@sap/hana-client": "^2.14.22", + "better-sqlite3": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0", + "ioredis": "^5.0.4", + "mongodb": "^5.8.0 || ^6.0.0", + "mssql": "^9.1.1 || ^10.0.0 || ^11.0.0 || ^12.0.0", + "mysql2": "^2.2.5 || ^3.0.1", + "oracledb": "^6.3.0", + "pg": "^8.5.1", + "pg-native": "^3.0.0", + "pg-query-stream": "^4.0.0", + "redis": "^3.1.1 || ^4.0.0 || ^5.0.14", + "sql.js": "^1.4.0", + "sqlite3": "^5.0.3", + "ts-node": "^10.7.0", + "typeorm-aurora-data-api-driver": "^2.0.0 || ^3.0.0" + }, + "peerDependenciesMeta": { + "@google-cloud/spanner": { + "optional": true + }, + "@sap/hana-client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mssql": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "oracledb": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-native": { + "optional": true + }, + "pg-query-stream": { + "optional": true + }, + "redis": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + }, + "ts-node": { + "optional": true + }, + "typeorm-aurora-data-api-driver": { + "optional": true + } + } + }, + "node_modules/typeorm/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://npm.vexere.net/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/typeorm/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://npm.vexere.net/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/typeorm/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://npm.vexere.net/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typeorm/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://npm.vexere.net/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typeorm/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://npm.vexere.net/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://npm.vexere.net/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://npm.vexere.net/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://npm.vexere.net/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://npm.vexere.net/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://npm.vexere.net/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://npm.vexere.net/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://npm.vexere.net/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://npm.vexere.net/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://npm.vexere.net/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://npm.vexere.net/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://npm.vexere.net/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://npm.vexere.net/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true, + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://npm.vexere.net/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://npm.vexere.net/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://npm.vexere.net/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://npm.vexere.net/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://npm.vexere.net/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000..695335a7b0 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "my-challenge", + "version": "1.0.0", + "private": true, + "scripts": { + "test:problem4": "node -r ts-node/register src/problem4/run-tests.ts", + "dev:problem5": "ts-node-dev --respawn --transpile-only src/problem5/src/server.ts", + "test:problem5": "node --test -r ts-node/register src/problem5/tests/integration/*.test.ts" + }, + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.2", + "pg": "^8.12.0", + "reflect-metadata": "^0.2.2", + "typeorm": "^0.3.24" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/node": "^20.14.11", + "ts-node": "^10.9.2", + "ts-node-dev": "^2.0.0", + "typescript": "^5.6.3" + } +} diff --git a/src/problem4/.keep b/src/problem2/.keep similarity index 100% rename from src/problem4/.keep rename to src/problem2/.keep diff --git a/src/problem2/index.html b/src/problem2/index.html deleted file mode 100644 index 4058a68bff..0000000000 --- a/src/problem2/index.html +++ /dev/null @@ -1,27 +0,0 @@ - - - - - Fancy Form - - - - - - - - -
-
Swap
- - - - - - - -
- - - - diff --git a/src/problem2/script.js b/src/problem2/script.js deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/problem2/style.css b/src/problem2/style.css deleted file mode 100644 index 915af91c72..0000000000 --- a/src/problem2/style.css +++ /dev/null @@ -1,8 +0,0 @@ -body { - display: flex; - flex-direction: row; - align-items: center; - justify-content: center; - min-width: 360px; - font-family: Arial, Helvetica, sans-serif; -} diff --git a/src/problem4/README.md b/src/problem4/README.md new file mode 100644 index 0000000000..6ec1667c20 --- /dev/null +++ b/src/problem4/README.md @@ -0,0 +1,42 @@ +# Problem 4 + +## Interpretation + +The prompt asks for the summation from `1` to `n`. + +For this implementation: + +- if `n >= 1`, return `1 + 2 + ... + n` +- if `n < 1`, return `0` because the range `1..n` contains no positive integers +- if `n` is not an integer, truncate toward zero first + +This keeps the contract consistent with the natural reading of "sum to n" from `1`. + +## Implementations + +### `sum_to_n_a` + +- Strategy: iterative loop +- Time: `O(n)` +- Space: `O(1)` + +### `sum_to_n_b` + +- Strategy: recursion +- Time: `O(n)` +- Space: `O(n)` + +### `sum_to_n_c` + +- Strategy: arithmetic formula +- Time: `O(1)` +- Space: `O(1)` + +## Run + +From the repository root: + +```bash +npm install +npm run test:problem4 +``` diff --git a/src/problem4/run-tests.ts b/src/problem4/run-tests.ts new file mode 100644 index 0000000000..053e1737c3 --- /dev/null +++ b/src/problem4/run-tests.ts @@ -0,0 +1,34 @@ +import { sum_to_n_a, sum_to_n_b, sum_to_n_c } from "./sum_to_n"; + +type TestCase = { + readonly n: number; + readonly expected: number; +}; + +function assertEqual(actual: number, expected: number, label: string): void { + if (actual !== expected) { + console.error(`FAIL ${label}: expected ${expected}, received ${actual}`); + process.exitCode = 1; + return; + } + + console.log(`OK ${label}: ${actual}`); +} + +const cases: TestCase[] = [ + { n: 5, expected: 15 }, + { n: 1, expected: 1 }, + { n: 0, expected: 0 }, + { n: -5, expected: 0 }, + { n: 10.8, expected: 55 } +]; + +for (const testCase of cases) { + assertEqual(sum_to_n_a(testCase.n), testCase.expected, `sum_to_n_a(${testCase.n})`); + assertEqual(sum_to_n_b(testCase.n), testCase.expected, `sum_to_n_b(${testCase.n})`); + assertEqual(sum_to_n_c(testCase.n), testCase.expected, `sum_to_n_c(${testCase.n})`); +} + +if (process.exitCode === undefined || process.exitCode === 0) { + console.log("All Problem 4 tests passed."); +} diff --git a/src/problem4/sum_to_n.ts b/src/problem4/sum_to_n.ts new file mode 100644 index 0000000000..c1e8841452 --- /dev/null +++ b/src/problem4/sum_to_n.ts @@ -0,0 +1,50 @@ +/** + * Problem 4: provide three unique implementations for summing integers up to n. + * + * For this solution: + * - If n >= 1, sum 1..n. + * - If n < 1, return 0 because there are no positive integers in the range 1..n. + * - Decimal inputs are truncated toward zero to keep the contract deterministic. + */ + +/** Iterative loop. Time: O(n), Space: O(1) */ +export function sum_to_n_a(n: number): number { + const limit = Math.trunc(n); + + if (limit < 1) { + return 0; + } + + let total = 0; + for (let value = 1; value <= limit; value += 1) { + total += value; + } + + return total; +} + +/** Recursive decomposition. Time: O(n), Space: O(n) due to call stack */ +export function sum_to_n_b(n: number): number { + const limit = Math.trunc(n); + + if (limit < 1) { + return 0; + } + + if (limit === 1) { + return 1; + } + + return limit + sum_to_n_b(limit - 1); +} + +/** Arithmetic series formula. Time: O(1), Space: O(1) */ +export function sum_to_n_c(n: number): number { + const limit = Math.trunc(n); + + if (limit < 1) { + return 0; + } + + return (limit * (limit + 1)) / 2; +} diff --git a/src/problem5/.env.example b/src/problem5/.env.example new file mode 100644 index 0000000000..811e78f1b0 --- /dev/null +++ b/src/problem5/.env.example @@ -0,0 +1,3 @@ +PORT=3000 +DATABASE_URL=postgres://app:app@127.0.0.1:5433/my_challenge +TEST_DATABASE_URL=postgres://app:app@127.0.0.1:5433/my_challenge_test diff --git a/src/problem5/.keep b/src/problem5/.keep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/problem5/README.md b/src/problem5/README.md new file mode 100644 index 0000000000..22641e9e73 --- /dev/null +++ b/src/problem5/README.md @@ -0,0 +1,583 @@ +# Problem 5: Resource Management API + +A RESTful API for managing resources built with Express, TypeORM, and PostgreSQL. + +This solution satisfies the CRUD requirement and intentionally highlights database skills by adding a concurrency-safe increment endpoint implemented with a transaction and PostgreSQL row locking. + +## Overview + +This backend provides: + +- resource creation +- resource listing with basic filters +- resource detail lookup +- resource updates +- resource deletion +- a concurrency-safe quantity increment operation + +The implementation uses: + +- **Framework**: Express +- **Language**: TypeScript +- **ORM**: TypeORM +- **Database**: PostgreSQL +- **Runtime DB Setup**: Docker Compose +- **Concurrency Control**: PostgreSQL `SELECT ... FOR UPDATE` via TypeORM `pessimistic_write` + +## Why This Design + +The original problem only requires CRUD plus persistence. I intentionally used PostgreSQL instead of SQLite to demonstrate stronger backend engineering around: + +- transaction boundaries +- row-level locking +- lost-update prevention +- real integration testing against a real database + +The extra endpoint `POST /resources/:id/increment` is the clearest way to show that skill in a coding test. + +## Project Structure + +```text +problem5/ + ├─ .env.example + ├─ docker-compose.yml + ├─ docker/ + │ └─ init/ + │ └─ 01-create-test-db.sql + ├─ src/ + │ ├─ app.ts + │ ├─ server.ts + │ ├─ config/ + │ ├─ controllers/ + │ ├─ db/ + │ ├─ entities/ + │ ├─ middleware/ + │ ├─ repositories/ + │ ├─ routes/ + │ ├─ services/ + │ ├─ types/ + │ ├─ utils/ + │ └─ validators/ + └─ tests/ + └─ integration/ +``` + +## Resource Model + +Each resource has: + +- `id`: auto-generated primary key +- `code`: required business identifier, unique per resource +- `name`: required string +- `description`: optional string, nullable +- `quantity`: integer field used for CRUD and concurrency-safe increments +- `createdAt`: timestamp +- `updatedAt`: timestamp + +Example persisted row: + +```json +{ + "id": 1, + "code": "SKU-001", + "name": "Warehouse Item", + "description": "Primary stock item", + "quantity": 10, + "createdAt": "2026-04-17T03:17:11.245Z", + "updatedAt": "2026-04-17T03:17:11.245Z" +} +``` + +## Prerequisites + +- Node.js 20+ +- npm +- Docker Desktop or a working Docker daemon + +## Setup + +### 1. Install dependencies + +From the repository root: + +```bash +cd my-challenge +npm install +``` + +### 2. Start PostgreSQL in Docker + +From the Problem 5 directory: + +```bash +cd my-challenge/src/problem5 +docker compose up -d +``` + +What this does: + +- starts PostgreSQL in a container +- exposes it on `127.0.0.1:5433` +- creates the main DB `my_challenge` +- creates the test DB `my_challenge_test` + +Check container status: + +```bash +docker compose ps +``` + +Expected healthy output shape: + +```text +NAME IMAGE STATUS PORTS +my-challenge-problem5-postgres postgres:16-alpine Up (healthy) 0.0.0.0:5433->5432/tcp +``` + +### 3. Configure environment + +You can rely on the built-in defaults, or create a local `.env`. + +Example: + +```bash +PORT=3000 +DATABASE_URL=postgres://app:app@127.0.0.1:5433/my_challenge +TEST_DATABASE_URL=postgres://app:app@127.0.0.1:5433/my_challenge_test +``` + +There is also a ready-made example file: + +```bash +cd my-challenge +cp src/problem5/.env.example .env +``` + +## Running the Application + +From the repository root: + +```bash +cd my-challenge +npm run dev:problem5 +``` + +Expected startup output: + +```text +Problem 5 server listening on http://localhost:3000 +``` + +## API Base URL + +All examples below use: + +```text +http://localhost:3000 +``` + +Resource routes live under: + +```text +http://localhost:3000/resources +``` + +## API Endpoints + +### 1. Create a Resource + +- **Method**: `POST` +- **URL**: `http://localhost:3000/resources` + +#### Example Request + +```bash +curl -X POST http://localhost:3000/resources \ + -H "Content-Type: application/json" \ + -d '{ + "code": "SKU-001", + "name": "Warehouse Item", + "description": "Primary stock item", + "quantity": 10 + }' +``` + +#### Example Response `201 Created` + +```json +{ + "id": 1, + "code": "SKU-001", + "name": "Warehouse Item", + "description": "Primary stock item", + "quantity": 10, + "createdAt": "2026-04-17T03:17:11.245Z", + "updatedAt": "2026-04-17T03:17:11.245Z" +} +``` + +#### Example Validation Error `400 Bad Request` + +```json +{ + "error": "Field 'code' is required" +} +``` + +#### Example Duplicate Code Error `409 Conflict` + +```json +{ + "error": "Resource code already exists" +} +``` + +### 2. List Resources + +- **Method**: `GET` +- **URL**: `http://localhost:3000/resources` + +#### Example Request: list all + +```bash +curl http://localhost:3000/resources +``` + +#### Example Response `200 OK` + +```json +[ + { + "id": 1, + "code": "SKU-001", + "name": "Warehouse Item", + "description": "Primary stock item", + "quantity": 10, + "createdAt": "2026-04-17T03:17:11.245Z", + "updatedAt": "2026-04-17T03:17:11.245Z" + }, + { + "id": 2, + "code": "SKU-002", + "name": "Overflow Item", + "description": "Stored in secondary area", + "quantity": 3, + "createdAt": "2026-04-17T03:17:32.001Z", + "updatedAt": "2026-04-17T03:17:32.001Z" + } +] +``` + +#### Example Request: filter by name + +```bash +curl "http://localhost:3000/resources?name=Warehouse" +``` + +#### Example Request: filter by minimum quantity + +```bash +curl "http://localhost:3000/resources?minQuantity=5" +``` + +#### Example Request: combined filters + +```bash +curl "http://localhost:3000/resources?name=Item&minQuantity=5" +``` + +#### Example Validation Error `400 Bad Request` + +```json +{ + "error": "Query parameter 'minQuantity' must be an integer" +} +``` + +### 3. Get Resource Details + +- **Method**: `GET` +- **URL**: `http://localhost:3000/resources/:id` + +#### Example Request + +```bash +curl http://localhost:3000/resources/1 +``` + +#### Example Response `200 OK` + +```json +{ + "id": 1, + "code": "SKU-001", + "name": "Warehouse Item", + "description": "Primary stock item", + "quantity": 10, + "createdAt": "2026-04-17T03:17:11.245Z", + "updatedAt": "2026-04-17T03:17:11.245Z" +} +``` + +#### Example Not Found `404 Not Found` + +```json +{ + "error": "Resource not found" +} +``` + +### 4. Update a Resource + +- **Method**: `PUT` +- **URL**: `http://localhost:3000/resources/:id` + +Partial update is supported. You can update any combination of: + +- `code` +- `name` +- `description` +- `quantity` + +#### Example Request + +```bash +curl -X PUT http://localhost:3000/resources/1 \ + -H "Content-Type: application/json" \ + -d '{ + "code": "SKU-001-A", + "name": "Warehouse Item - Updated", + "description": "Updated stock label", + "quantity": 25 + }' +``` + +#### Example Response `200 OK` + +```json +{ + "id": 1, + "code": "SKU-001-A", + "name": "Warehouse Item - Updated", + "description": "Updated stock label", + "quantity": 25, + "createdAt": "2026-04-17T03:17:11.245Z", + "updatedAt": "2026-04-17T03:20:42.552Z" +} +``` + +#### Example Validation Error `400 Bad Request` + +```json +{ + "error": "At least one updatable field is required" +} +``` + +#### Example Duplicate Code Error `409 Conflict` + +```json +{ + "error": "Resource code already exists" +} +``` + +### 5. Increment Resource Quantity Safely + +- **Method**: `POST` +- **URL**: `http://localhost:3000/resources/:id/increment` + +This endpoint exists to demonstrate database correctness under concurrency. + +Internally, the service: + +1. opens a transaction +2. reads the target row with a pessimistic write lock +3. calculates the new quantity while holding the lock +4. writes the updated quantity +5. commits the transaction + +In PostgreSQL, that maps to `SELECT ... FOR UPDATE`. + +#### Example Request + +```bash +curl -X POST http://localhost:3000/resources/1/increment \ + -H "Content-Type: application/json" \ + -d '{ + "amount": 5 + }' +``` + +#### Example Response `200 OK` + +```json +{ + "id": 1, + "code": "SKU-001-A", + "name": "Warehouse Item - Updated", + "description": "Updated stock label", + "quantity": 30, + "createdAt": "2026-04-17T03:17:11.245Z", + "updatedAt": "2026-04-17T03:21:03.044Z" +} +``` + +#### Example Validation Error `400 Bad Request` + +```json +{ + "error": "Field 'amount' must be a non-zero integer" +} +``` + +#### Why this matters + +If 20 parallel requests increment the same row by `5`, the final value should be: + +```text +initial_quantity + (20 * 5) +``` + +The integration test proves this behavior using a real PostgreSQL database. + +### 6. Delete a Resource + +- **Method**: `DELETE` +- **URL**: `http://localhost:3000/resources/:id` + +#### Example Request + +```bash +curl -X DELETE http://localhost:3000/resources/1 +``` + +#### Example Response `200 OK` + +```json +{ + "message": "Resource deleted successfully" +} +``` + +#### Example Not Found `404 Not Found` + +```json +{ + "error": "Resource not found" +} +``` + +## End-to-End Example Flow + +### Create + +```bash +curl -X POST http://localhost:3000/resources \ + -H "Content-Type: application/json" \ + -d '{ + "code": "DEMO-001", + "name": "Demo Item", + "description": "Created during README walkthrough", + "quantity": 12 + }' +``` + +### List + +```bash +curl http://localhost:3000/resources +``` + +### Get by ID + +```bash +curl http://localhost:3000/resources/1 +``` + +### Update + +```bash +curl -X PUT http://localhost:3000/resources/1 \ + -H "Content-Type: application/json" \ + -d '{ + "quantity": 20 + }' +``` + +### Increment with lock + +```bash +curl -X POST http://localhost:3000/resources/1/increment \ + -H "Content-Type: application/json" \ + -d '{ + "amount": 7 + }' +``` + +### Delete + +```bash +curl -X DELETE http://localhost:3000/resources/1 +``` + +## Integration Testing + +The integration test suite uses: + +- a real PostgreSQL database +- real HTTP requests against the Express app +- setup hooks +- cleanup hooks +- a concurrency test for the row-locking path + +Run from the repository root: + +```bash +cd my-challenge +npm run test:problem5 +``` + +### What the tests cover + +1. create and retrieve a resource +2. run many parallel increment requests against the same row +3. verify that the final quantity is exact and no update is lost + +## Troubleshooting + +### API cannot connect to PostgreSQL + +Check the Docker container: + +```bash +cd my-challenge/src/problem5 +docker compose ps +``` + +### Port 3000 is already in use + +Set a different `PORT` in `.env`: + +```bash +PORT=3001 +``` + +### Port 5433 is already in use + +Change the host-side port mapping in `docker-compose.yml` and update `DATABASE_URL` / `TEST_DATABASE_URL`. + +### Node modules missing + +From the repository root: + +```bash +npm install +``` + +## Notes + +- `synchronize: true` is enabled for speed in a coding exercise. +- In production, I would replace that with migrations. +- The Docker database setup makes the project reproducible on another machine. +- The increment endpoint is beyond the minimum CRUD requirement, but it is included deliberately to showcase transaction handling and lost-update prevention in a senior backend interview. diff --git a/src/problem5/docker-compose.yml b/src/problem5/docker-compose.yml new file mode 100644 index 0000000000..d8d7b9a177 --- /dev/null +++ b/src/problem5/docker-compose.yml @@ -0,0 +1,22 @@ +services: + postgres: + image: postgres:16-alpine + container_name: my-challenge-problem5-postgres + restart: unless-stopped + environment: + POSTGRES_USER: app + POSTGRES_PASSWORD: app + POSTGRES_DB: my_challenge + ports: + - "5433:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./docker/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U app -d my_challenge"] + interval: 5s + timeout: 5s + retries: 20 + +volumes: + postgres_data: diff --git a/src/problem5/docker/init/01-create-test-db.sql b/src/problem5/docker/init/01-create-test-db.sql new file mode 100644 index 0000000000..2574e6a496 --- /dev/null +++ b/src/problem5/docker/init/01-create-test-db.sql @@ -0,0 +1 @@ +CREATE DATABASE my_challenge_test; diff --git a/src/problem5/src/app.ts b/src/problem5/src/app.ts new file mode 100644 index 0000000000..5ea685bfdf --- /dev/null +++ b/src/problem5/src/app.ts @@ -0,0 +1,16 @@ +import express from "express"; +import cors from "cors"; +import { DataSource } from "typeorm"; +import { createResourceRouter } from "./routes/resource.routes"; +import { errorHandler } from "./middleware/error-handler"; + +export function createApp(dataSource: DataSource) { + const app = express(); + + app.use(cors()); + app.use(express.json()); + app.use("/resources", createResourceRouter(dataSource)); + app.use(errorHandler); + + return app; +} diff --git a/src/problem5/src/config/env.ts b/src/problem5/src/config/env.ts new file mode 100644 index 0000000000..1b46a7df55 --- /dev/null +++ b/src/problem5/src/config/env.ts @@ -0,0 +1,23 @@ +import dotenv from "dotenv"; + +dotenv.config(); + +const defaultDatabaseUrl = "postgres://app:app@127.0.0.1:5433/my_challenge"; +const defaultTestDatabaseUrl = "postgres://app:app@127.0.0.1:5433/my_challenge_test"; + +function readEnv(name: string, fallback?: string): string { + const value = process.env[name] ?? fallback; + + if (value === undefined) { + throw new Error(`Missing required environment variable: ${name}`); + } + + return value; +} + +export const env = { + nodeEnv: process.env.NODE_ENV ?? "development", + port: Number(process.env.PORT ?? 3000), + databaseUrl: readEnv("DATABASE_URL", defaultDatabaseUrl), + testDatabaseUrl: readEnv("TEST_DATABASE_URL", defaultTestDatabaseUrl) +}; diff --git a/src/problem5/src/controllers/resource.controller.ts b/src/problem5/src/controllers/resource.controller.ts new file mode 100644 index 0000000000..3c97f309dc --- /dev/null +++ b/src/problem5/src/controllers/resource.controller.ts @@ -0,0 +1,64 @@ +import { Request, Response } from "express"; +import { ResourceService } from "../services/resource.service"; +import { HttpError } from "../utils/http-error"; +import { + validateCreateResourceInput, + validateIncrementResourceInput, + validateResourceId, + validateUpdateResourceInput +} from "../validators/resource.validator"; + +export class ResourceController { + public constructor(private readonly resourceService: ResourceService) {} + + public createResource = async (req: Request, res: Response): Promise => { + const input = validateCreateResourceInput(req.body); + const resource = await this.resourceService.createResource(input); + res.status(201).json(resource); + }; + + public listResources = async (req: Request, res: Response): Promise => { + const minQuantityRaw = req.query.minQuantity; + let minQuantity: number | undefined; + + if (typeof minQuantityRaw === "string" && minQuantityRaw.length > 0) { + minQuantity = Number(minQuantityRaw); + if (!Number.isInteger(minQuantity)) { + throw new HttpError(400, "Query parameter 'minQuantity' must be an integer"); + } + } + + const resources = await this.resourceService.listResources({ + name: typeof req.query.name === "string" ? req.query.name : undefined, + minQuantity + }); + + res.status(200).json(resources); + }; + + public getResource = async (req: Request, res: Response): Promise => { + const resourceId = validateResourceId(req.params.id); + const resource = await this.resourceService.getResourceById(resourceId); + res.status(200).json(resource); + }; + + public updateResource = async (req: Request, res: Response): Promise => { + const resourceId = validateResourceId(req.params.id); + const input = validateUpdateResourceInput(req.body); + const resource = await this.resourceService.updateResource(resourceId, input); + res.status(200).json(resource); + }; + + public incrementResourceQuantity = async (req: Request, res: Response): Promise => { + const resourceId = validateResourceId(req.params.id); + const input = validateIncrementResourceInput(req.body); + const resource = await this.resourceService.incrementQuantityWithLock(resourceId, input); + res.status(200).json(resource); + }; + + public deleteResource = async (req: Request, res: Response): Promise => { + const resourceId = validateResourceId(req.params.id); + await this.resourceService.deleteResource(resourceId); + res.status(200).json({ message: "Resource deleted successfully" }); + }; +} diff --git a/src/problem5/src/db/data-source.ts b/src/problem5/src/db/data-source.ts new file mode 100644 index 0000000000..fb962e361f --- /dev/null +++ b/src/problem5/src/db/data-source.ts @@ -0,0 +1,14 @@ +import "reflect-metadata"; +import { DataSource } from "typeorm"; +import { env } from "../config/env"; +import { ResourceEntity } from "../entities/resource.entity"; + +export function createAppDataSource(databaseUrl = env.databaseUrl): DataSource { + return new DataSource({ + type: "postgres", + url: databaseUrl, + entities: [ResourceEntity], + synchronize: true, + logging: false + }); +} diff --git a/src/problem5/src/entities/resource.entity.ts b/src/problem5/src/entities/resource.entity.ts new file mode 100644 index 0000000000..f63963fde0 --- /dev/null +++ b/src/problem5/src/entities/resource.entity.ts @@ -0,0 +1,33 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn +} from "typeorm"; + +@Entity({ name: "resources" }) +export class ResourceEntity { + @PrimaryGeneratedColumn() + id!: number; + + @Index("uq_resources_code", { unique: true }) + @Column({ type: "varchar", length: 64 }) + code!: string; + + @Column({ type: "varchar", length: 120 }) + name!: string; + + @Column({ type: "varchar", length: 500, nullable: true }) + description!: string | null; + + @Column({ type: "integer", default: 0 }) + quantity!: number; + + @CreateDateColumn({ name: "created_at" }) + createdAt!: Date; + + @UpdateDateColumn({ name: "updated_at" }) + updatedAt!: Date; +} diff --git a/src/problem5/src/middleware/async-handler.ts b/src/problem5/src/middleware/async-handler.ts new file mode 100644 index 0000000000..b5a7d5af08 --- /dev/null +++ b/src/problem5/src/middleware/async-handler.ts @@ -0,0 +1,9 @@ +import { NextFunction, Request, RequestHandler, Response } from "express"; + +type AsyncHandler = (req: Request, res: Response, next: NextFunction) => Promise; + +export function asyncHandler(handler: AsyncHandler): RequestHandler { + return (req, res, next) => { + void handler(req, res, next).catch(next); + }; +} diff --git a/src/problem5/src/middleware/error-handler.ts b/src/problem5/src/middleware/error-handler.ts new file mode 100644 index 0000000000..f123a09b69 --- /dev/null +++ b/src/problem5/src/middleware/error-handler.ts @@ -0,0 +1,17 @@ +import { NextFunction, Request, Response } from "express"; +import { HttpError } from "../utils/http-error"; + +export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction): void { + if (err instanceof HttpError) { + res.status(err.statusCode).json({ + error: err.message, + details: err.details + }); + return; + } + + console.error(err); + res.status(500).json({ + error: "Internal server error" + }); +} diff --git a/src/problem5/src/repositories/resource.repository.ts b/src/problem5/src/repositories/resource.repository.ts new file mode 100644 index 0000000000..48b868ed60 --- /dev/null +++ b/src/problem5/src/repositories/resource.repository.ts @@ -0,0 +1,44 @@ +import { DataSource, Repository } from "typeorm"; +import { ResourceEntity } from "../entities/resource.entity"; + +export class ResourceRepository { + private readonly repository: Repository; + + public constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(ResourceEntity); + } + + public create(data: Partial): ResourceEntity { + return this.repository.create(data); + } + + public save(resource: ResourceEntity): Promise { + return this.repository.save(resource); + } + + public findById(id: number): Promise { + return this.repository.findOne({ where: { id } }); + } + + public findByCode(code: string): Promise { + return this.repository.findOne({ where: { code } }); + } + + public findWithFilters(filters: { readonly name?: string; readonly minQuantity?: number }): Promise { + const queryBuilder = this.repository.createQueryBuilder("resource"); + + if (filters.name) { + queryBuilder.andWhere("resource.name ILIKE :name", { name: `%${filters.name}%` }); + } + + if (filters.minQuantity !== undefined) { + queryBuilder.andWhere("resource.quantity >= :minQuantity", { minQuantity: filters.minQuantity }); + } + + return queryBuilder.orderBy("resource.id", "ASC").getMany(); + } + + public remove(resource: ResourceEntity): Promise { + return this.repository.remove(resource); + } +} diff --git a/src/problem5/src/routes/resource.routes.ts b/src/problem5/src/routes/resource.routes.ts new file mode 100644 index 0000000000..f8fe732064 --- /dev/null +++ b/src/problem5/src/routes/resource.routes.ts @@ -0,0 +1,19 @@ +import { Router } from "express"; +import { DataSource } from "typeorm"; +import { ResourceController } from "../controllers/resource.controller"; +import { asyncHandler } from "../middleware/async-handler"; +import { ResourceService } from "../services/resource.service"; + +export function createResourceRouter(dataSource: DataSource): Router { + const router = Router(); + const resourceController = new ResourceController(new ResourceService(dataSource)); + + router.post("/", asyncHandler(resourceController.createResource)); + router.get("/", asyncHandler(resourceController.listResources)); + router.get("/:id", asyncHandler(resourceController.getResource)); + router.put("/:id", asyncHandler(resourceController.updateResource)); + router.post("/:id/increment", asyncHandler(resourceController.incrementResourceQuantity)); + router.delete("/:id", asyncHandler(resourceController.deleteResource)); + + return router; +} diff --git a/src/problem5/src/server.ts b/src/problem5/src/server.ts new file mode 100644 index 0000000000..c406bb1349 --- /dev/null +++ b/src/problem5/src/server.ts @@ -0,0 +1,21 @@ +import { createServer } from "node:http"; +import { env } from "./config/env"; +import { createApp } from "./app"; +import { createAppDataSource } from "./db/data-source"; + +async function bootstrap(): Promise { + const dataSource = createAppDataSource(); + await dataSource.initialize(); + + const app = createApp(dataSource); + const server = createServer(app); + + server.listen(env.port, () => { + console.log(`Problem 5 server listening on http://localhost:${env.port}`); + }); +} + +void bootstrap().catch((error) => { + console.error("Failed to start Problem 5 server", error); + process.exitCode = 1; +}); diff --git a/src/problem5/src/services/resource.service.ts b/src/problem5/src/services/resource.service.ts new file mode 100644 index 0000000000..e3f90a6e72 --- /dev/null +++ b/src/problem5/src/services/resource.service.ts @@ -0,0 +1,125 @@ +import { DataSource, QueryFailedError } from "typeorm"; +import { ResourceEntity } from "../entities/resource.entity"; +import { ResourceRepository } from "../repositories/resource.repository"; +import { CreateResourceInput, IncrementResourceInput, UpdateResourceInput } from "../types/resource"; +import { HttpError } from "../utils/http-error"; +import { sleep } from "../utils/sleep"; + +export class ResourceService { + private readonly repository: ResourceRepository; + + public constructor(private readonly dataSource: DataSource) { + this.repository = new ResourceRepository(dataSource); + } + + public async createResource(input: CreateResourceInput): Promise { + const normalizedCode = ResourceService.normalizeCode(input.code); + await this.ensureCodeIsAvailable(normalizedCode); + + try { + const resource = this.repository.create({ + code: normalizedCode, + name: input.name, + description: input.description ?? null, + quantity: input.quantity ?? 0 + }); + + return await this.repository.save(resource); + } catch (error) { + throw ResourceService.mapPersistenceError(error); + } + } + + public listResources(filters: { readonly name?: string; readonly minQuantity?: number }): Promise { + return this.repository.findWithFilters(filters); + } + + public async getResourceById(id: number): Promise { + const resource = await this.repository.findById(id); + + if (!resource) { + throw new HttpError(404, "Resource not found"); + } + + return resource; + } + + public async updateResource(id: number, input: UpdateResourceInput): Promise { + const resource = await this.getResourceById(id); + console.log("🚀 ~ ResourceService ~ updateResource ~ resource:", resource) + + if (input.code !== undefined) { + const normalizedCode = ResourceService.normalizeCode(input.code); + await this.ensureCodeIsAvailable(normalizedCode, resource.id); + resource.code = normalizedCode; + } + + if (input.name !== undefined) { + resource.name = input.name; + } + + if (input.description !== undefined) { + resource.description = input.description; + } + + if (input.quantity !== undefined) { + resource.quantity = input.quantity; + } + + try { + return await this.repository.save(resource); + } catch (error) { + throw ResourceService.mapPersistenceError(error); + } + } + + public async deleteResource(id: number): Promise { + const resource = await this.getResourceById(id); + await this.repository.remove(resource); + } + + public async incrementQuantityWithLock(id: number, input: IncrementResourceInput): Promise { + return this.dataSource.transaction(async (transactionManager) => { + const repository = transactionManager.getRepository(ResourceEntity); + const resource = await repository + .createQueryBuilder("resource") + .setLock("pessimistic_write") + .where("resource.id = :id", { id }) + .getOne(); + + if (!resource) { + throw new HttpError(404, "Resource not found"); + } + + if (input.simulateDelayMs && input.simulateDelayMs > 0) { + await sleep(input.simulateDelayMs); + } + + resource.quantity += input.amount; + return repository.save(resource); + }); + } + + private static normalizeCode(code: string): string { + return code.trim().toUpperCase(); + } + + private async ensureCodeIsAvailable(code: string, currentResourceId?: number): Promise { + const existingResource = await this.repository.findByCode(code); + + if (existingResource && existingResource.id !== currentResourceId) { + throw new HttpError(409, "Resource code already exists"); + } + } + + private static mapPersistenceError(error: unknown): Error { + if (error instanceof QueryFailedError) { + const driverError = error.driverError as { code?: string } | undefined; + if (driverError?.code === "23505") { + return new HttpError(409, "Resource code already exists"); + } + } + + return error instanceof Error ? error : new Error("Unknown persistence error"); + } +} diff --git a/src/problem5/src/types/resource.ts b/src/problem5/src/types/resource.ts new file mode 100644 index 0000000000..ddf51c3f1e --- /dev/null +++ b/src/problem5/src/types/resource.ts @@ -0,0 +1,18 @@ +export type CreateResourceInput = { + code: string; + name: string; + description?: string; + quantity?: number; +}; + +export type UpdateResourceInput = { + code?: string; + name?: string; + description?: string | null; + quantity?: number; +}; + +export type IncrementResourceInput = { + amount: number; + simulateDelayMs?: number; +}; diff --git a/src/problem5/src/utils/http-error.ts b/src/problem5/src/utils/http-error.ts new file mode 100644 index 0000000000..9b5e506457 --- /dev/null +++ b/src/problem5/src/utils/http-error.ts @@ -0,0 +1,10 @@ +export class HttpError extends Error { + public readonly statusCode: number; + public readonly details?: unknown; + + public constructor(statusCode: number, message: string, details?: unknown) { + super(message); + this.statusCode = statusCode; + this.details = details; + } +} diff --git a/src/problem5/src/utils/sleep.ts b/src/problem5/src/utils/sleep.ts new file mode 100644 index 0000000000..df0d03d81c --- /dev/null +++ b/src/problem5/src/utils/sleep.ts @@ -0,0 +1,5 @@ +export function sleep(milliseconds: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, milliseconds); + }); +} diff --git a/src/problem5/src/validators/resource.validator.ts b/src/problem5/src/validators/resource.validator.ts new file mode 100644 index 0000000000..96f2793fa2 --- /dev/null +++ b/src/problem5/src/validators/resource.validator.ts @@ -0,0 +1,134 @@ +import { HttpError } from "../utils/http-error"; +import { CreateResourceInput, IncrementResourceInput, UpdateResourceInput } from "../types/resource"; + +function asTrimmedString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function asInteger(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isInteger(value)) { + return undefined; + } + + return value; +} + +export function validateResourceId(rawId: string): number { + const id = Number(rawId); + + if (!Number.isInteger(id) || id < 1) { + throw new HttpError(400, "Resource id must be a positive integer"); + } + + return id; +} + +export function validateCreateResourceInput(body: unknown): CreateResourceInput { + if (body === null || typeof body !== "object") { + throw new HttpError(400, "Request body must be an object"); + } + + const payload = body as Record; + const code = asTrimmedString(payload.code); + const name = asTrimmedString(payload.name); + const description = payload.description === undefined ? undefined : asTrimmedString(payload.description); + const quantity = payload.quantity === undefined ? undefined : asInteger(payload.quantity); + + if (!code) { + throw new HttpError(400, "Field 'code' is required"); + } + + if (!name) { + throw new HttpError(400, "Field 'name' is required"); + } + + if (payload.quantity !== undefined && quantity === undefined) { + throw new HttpError(400, "Field 'quantity' must be an integer"); + } + + return { + code, + name, + description, + quantity + }; +} + +export function validateUpdateResourceInput(body: unknown): UpdateResourceInput { + if (body === null || typeof body !== "object") { + throw new HttpError(400, "Request body must be an object"); + } + + const payload = body as Record; + const result: UpdateResourceInput = {}; + + if (payload.code !== undefined) { + const code = asTrimmedString(payload.code); + if (!code) { + throw new HttpError(400, "Field 'code' must be a non-empty string"); + } + result.code = code; + } + + if (payload.name !== undefined) { + const name = asTrimmedString(payload.name); + if (!name) { + throw new HttpError(400, "Field 'name' must be a non-empty string"); + } + result.name = name; + } + + if (payload.description !== undefined) { + if (payload.description === null) { + result.description = null; + } else { + const description = asTrimmedString(payload.description); + if (!description) { + throw new HttpError(400, "Field 'description' must be a non-empty string or null"); + } + result.description = description; + } + } + + if (payload.quantity !== undefined) { + const quantity = asInteger(payload.quantity); + if (quantity === undefined) { + throw new HttpError(400, "Field 'quantity' must be an integer"); + } + result.quantity = quantity; + } + + if (Object.keys(result).length === 0) { + throw new HttpError(400, "At least one updatable field is required"); + } + + return result; +} + +export function validateIncrementResourceInput(body: unknown): IncrementResourceInput { + if (body === null || typeof body !== "object") { + throw new HttpError(400, "Request body must be an object"); + } + + const payload = body as Record; + const amount = asInteger(payload.amount); + + if (amount === undefined || amount === 0) { + throw new HttpError(400, "Field 'amount' must be a non-zero integer"); + } + + let simulateDelayMs: number | undefined; + if (payload.simulateDelayMs !== undefined) { + simulateDelayMs = asInteger(payload.simulateDelayMs); + if (simulateDelayMs === undefined || simulateDelayMs < 0) { + throw new HttpError(400, "Field 'simulateDelayMs' must be a non-negative integer"); + } + } + + return { amount, simulateDelayMs }; +} diff --git a/src/problem5/tests/integration/resource.integration.test.ts b/src/problem5/tests/integration/resource.integration.test.ts new file mode 100644 index 0000000000..80fec6793c --- /dev/null +++ b/src/problem5/tests/integration/resource.integration.test.ts @@ -0,0 +1,181 @@ +import test, { after, afterEach, before } from "node:test"; +import assert from "node:assert/strict"; +import { AddressInfo } from "node:net"; +import { Server } from "node:http"; +import { DataSource } from "typeorm"; +import { createApp } from "../../src/app"; +import { env } from "../../src/config/env"; +import { createAppDataSource } from "../../src/db/data-source"; +import { ResourceEntity } from "../../src/entities/resource.entity"; + +let dataSource: DataSource; +let server: Server; +let baseUrl: string; + +before(async () => { + dataSource = createAppDataSource(env.testDatabaseUrl); + await dataSource.initialize(); + + const app = createApp(dataSource); + server = app.listen(0); + + await new Promise((resolve) => { + server.once("listening", () => resolve()); + }); + + const address = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${address.port}`; +}); + +afterEach(async () => { + await dataSource.query("TRUNCATE TABLE resources RESTART IDENTITY CASCADE"); +}); + +after(async () => { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); + + await dataSource.destroy(); +}); + +test("creates and retrieves a resource", { concurrency: false }, async () => { + const createResponse = await fetch(`${baseUrl}/resources`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + code: "sku-001", + name: "Warehouse item", + description: "Created from integration test", + quantity: 10 + }) + }); + + assert.equal(createResponse.status, 201); + const createdResource = (await createResponse.json()) as ResourceEntity; + assert.equal(createdResource.code, "SKU-001"); + assert.equal(createdResource.name, "Warehouse item"); + assert.equal(createdResource.quantity, 10); + + const getResponse = await fetch(`${baseUrl}/resources/${createdResource.id}`); + assert.equal(getResponse.status, 200); + + const fetchedResource = (await getResponse.json()) as ResourceEntity; + assert.equal(fetchedResource.id, createdResource.id); + assert.equal(fetchedResource.code, "SKU-001"); + assert.equal(fetchedResource.description, "Created from integration test"); +}); + +test("rejects duplicate resource code on create", { concurrency: false }, async () => { + const firstCreateResponse = await fetch(`${baseUrl}/resources`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + code: "SKU-001", + name: "First item", + quantity: 10 + }) + }); + + assert.equal(firstCreateResponse.status, 201); + + const duplicateCreateResponse = await fetch(`${baseUrl}/resources`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + code: "sku-001", + name: "Duplicate item", + quantity: 5 + }) + }); + + assert.equal(duplicateCreateResponse.status, 409); + const payload = (await duplicateCreateResponse.json()) as { error: string }; + assert.equal(payload.error, "Resource code already exists"); +}); + +test("rejects duplicate resource code on update", { concurrency: false }, async () => { + const firstCreateResponse = await fetch(`${baseUrl}/resources`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + code: "SKU-001", + name: "First item", + quantity: 10 + }) + }); + assert.equal(firstCreateResponse.status, 201); + const firstResource = (await firstCreateResponse.json()) as ResourceEntity; + + const secondCreateResponse = await fetch(`${baseUrl}/resources`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + code: "SKU-002", + name: "Second item", + quantity: 4 + }) + }); + assert.equal(secondCreateResponse.status, 201); + const secondResource = (await secondCreateResponse.json()) as ResourceEntity; + + const updateResponse = await fetch(`${baseUrl}/resources/${secondResource.id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + code: "sku-001" + }) + }); + + assert.equal(updateResponse.status, 409); + const payload = (await updateResponse.json()) as { error: string }; + assert.equal(payload.error, "Resource code already exists"); + + const repository = dataSource.getRepository(ResourceEntity); + const reloadedFirst = await repository.findOneByOrFail({ id: firstResource.id }); + const reloadedSecond = await repository.findOneByOrFail({ id: secondResource.id }); + assert.equal(reloadedFirst.code, "SKU-001"); + assert.equal(reloadedSecond.code, "SKU-002"); +}); + +test("prevents lost updates under parallel increment requests using a pessimistic row lock", { concurrency: false }, async () => { + const repository = dataSource.getRepository(ResourceEntity); + const resource = await repository.save( + repository.create({ + code: "SKU-LOCK-001", + name: "Concurrent counter", + description: "Used to verify SELECT ... FOR UPDATE behavior", + quantity: 0 + }) + ); + + const requestCount = 20; + const incrementAmount = 5; + + const responses = await Promise.all( + Array.from({ length: requestCount }, async () => + fetch(`${baseUrl}/resources/${resource.id}/increment`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + amount: incrementAmount, + simulateDelayMs: 50 + }) + }) + ) + ); + + for (const response of responses) { + assert.equal(response.status, 200); + } + + const reloaded = await repository.findOneByOrFail({ id: resource.id }); + assert.equal(reloaded.quantity, requestCount * incrementAmount); +}); diff --git a/src/problem6/ARCHITECTURE.md b/src/problem6/ARCHITECTURE.md new file mode 100644 index 0000000000..332f8533e9 --- /dev/null +++ b/src/problem6/ARCHITECTURE.md @@ -0,0 +1,1128 @@ +# Problem 6: Architecture & Execution Flows + +This document is the detailed visual companion to `README.md`. + +The goal is to make the system implementation concrete enough for a backend team to build without needing to infer the critical write-path, projection, fraud, and recovery behavior. + +Unlike the passed example, this design does not update PostgreSQL, Redis, and realtime sockets in one synchronous chain. PostgreSQL remains authoritative, and downstream state is projected asynchronously through outbox + CDC + Kafka. + +This revision also removes the synchronous write-side total row. The authoritative write path is append-only: + +- accepted score events are written to PostgreSQL +- Redis computes the live query model +- an asynchronous PostgreSQL materialization worker can persist durable fallback totals outside the write transaction + +--- + +## 1. High-Level System Architecture + +### 1.1 Topology at a glance + +```text +Clients + | + v +┌────────────────────────────────────────────────────────────┐ +│ API Gateway / Load Balancer │ +│ - TLS termination │ +│ - request routing │ +│ - coarse rate limiting │ +│ - auth pre-check hooks │ +└───────────────┬──────────────────────┬────────────────────┘ + │ │ + │ HTTP │ WSS + ▼ ▼ + ┌──────────────────────┐ ┌──────────────────────┐ + │ score-command-api │ │ socket gateway │ + │ - auth + validation │ │ - connection auth │ + │ - append score event │ │ - channel fanout │ + │ - write outbox in tx │ └──────────▲───────────┘ + └──────────┬───────────┘ │ + │ │ leaderboard:initial + ▼ │ leaderboard:update + ┌──────────────────────┐ │ + │ PostgreSQL │ │ + │ - users / sessions │ │ + │ - requests / events │ │ + │ - outbox / flags │ │ + │ - materialized totals│ │ + └──────────┬───────────┘ │ + │ committed outbox rows │ + ▼ │ + ┌──────────────────────┐ │ + │ CDC Connector │ │ + │ Debezium / similar │ │ + │ - reads committed │ │ + │ outbox rows │ │ + └──────────┬───────────┘ │ + ▼ │ + ┌──────────────────────┐ │ + │ Kafka │──────────────┼──────────────────────┐ + │ - score-events topic │ │ │ + │ - replay source │ │ │ + │ - per-user ordering │ │ │ + └───────┬────────┬─────┘ │ │ + │ │ │ │ + ▼ ▼ │ │ + ┌──────────────────┐ ┌──────────────────────┐ │ + │ score-projection │ │ score-materialization│ │ + │ service / query │ │ worker │ │ + │ facade │ │ - async PG totals │ │ + │ - Kafka consumer │ │ - replay checkpoint │ │ + │ - read APIs │ └──────────┬───────────┘ │ + │ - Redis updater │ │ writes fallback state │ + │ - fanout decision│─────────────┘ into PostgreSQL │ + └──────────┬───────┘ │ + │ read / write │ + ▼ │ + ┌──────────────────────┐ │ + │ Redis │─────────────────────────────────────┘ + │ - leaderboard │ visible leaderboard updates only + │ - user projections │ + │ - fraud windows │ + └──────────────────────┘ +``` + +```text +Clients + | + v +API Gateway / Load Balancer + | + +--> POST /v1/actions/complete + | | + | v + | score-command-api + | | + | +--> authenticate + validate + | +--> append score_events + | +--> write outbox_events in same tx + | | + | v + | PostgreSQL + | | + | v + | CDC Connector + | | + | v + | Kafka score-events + | | + | +--> score-projection-service / query facade + | | | + | | +--> update Redis leaderboard + user projections + fraud windows + | | +--> decide leaderboard fanout + | | +--> push leaderboard:* to socket gateway + | | + | +--> score-materialization-worker + | | + | +--> upsert async PostgreSQL materialized totals + | + +--> GET /v1/leaderboard/top10 + | GET /v1/users/:userId/score + | | + | v + | score-projection-service / query facade + | | + | +--> read Redis live projections + | +--> optional degraded fallback from PostgreSQL materialized totals + | + +--> WSS /leaderboard/live + | + v + socket gateway + | + +--> authenticate connection + +--> deliver leaderboard:initial / leaderboard:update +``` + +Current system truth: + +- `POST /v1/actions/complete` goes through `score-command-api` and commits only to PostgreSQL in the write transaction. +- PostgreSQL outbox rows are streamed by CDC into Kafka after commit. +- Kafka feeds both the Redis projection path and the asynchronous PostgreSQL materialization path. +- `GET /v1/leaderboard/top10` and `GET /v1/users/:userId/score` are served from the projection/query side backed by Redis. +- WebSocket clients connect to `socket gateway`, which receives `leaderboard:*` fanout from the projection service, not directly from the write transaction. + +### 1.2 Mermaid view + +```mermaid +flowchart LR + Client[Web / Mobile Client] + Gateway[API Gateway / Load Balancer] + Command[score-command-api] + Query[score-projection-service / query facade] + Materialize[score-materialization-worker] + Socket[Socket Gateway] + PG[(PostgreSQL)] + CDC[CDC Connector\nDebezium] + Kafka[(Kafka)] + Redis[(Redis)] + + Client -->|POST /v1/actions/complete| Gateway + Client -->|GET /v1/leaderboard/top10| Gateway + Client -->|GET /v1/users/:userId/score| Gateway + Client -->|WSS /leaderboard/live| Gateway + + Gateway --> Command + Gateway --> Query + Gateway --> Socket + + Command -->|transaction: requests + events + outbox| PG + PG -->|committed outbox rows| CDC + CDC -->|score-events| Kafka + Kafka --> Query + Kafka --> Materialize + Query --> Redis + Query --> Socket + Materialize -->|async durable totals| PG + Query -->|optional replay reads| PG +``` + +### 1.2.1 ASCII alternative + +```text +Clients + | + v +API Gateway / Load Balancer + | + +--> POST /v1/actions/complete --> score-command-api --> PostgreSQL --> CDC Connector --> Kafka + | + +--> GET /v1/leaderboard/top10 --> score-projection-service / query facade --> Redis + | + +--> GET /v1/users/:userId/score --> score-projection-service / query facade --> Redis + | + +--> WSS /leaderboard/live --> Socket Gateway + +Kafka --> score-projection-service / query facade --> Redis +Kafka --> score-projection-service / query facade --> Socket Gateway +Kafka --> score-materialization-worker --> PostgreSQL durable totals +``` + +### 1.3 Component responsibilities + +| Component | Primary responsibility | Must not own | +| ------------------------------ | ----------------------------------------------------------------------- | ------------------------------------------ | +| API Gateway | routing, TLS, coarse throttling | authoritative score mutation | +| `score-command-api` | auth, action validation, transactional score writes, outbox writes | direct Redis leaderboard truth | +| PostgreSQL | source of truth for accepted writes and durable fallback state | low-latency top-10 serving | +| CDC connector | move committed outbox rows into Kafka | business validation | +| Kafka | durable event transport and replay | authoritative score state | +| `score-projection-service` | consume events, update Redis, serve read APIs or power thin query layer | deciding whether a score mutation is valid | +| `score-materialization-worker` | persist durable PostgreSQL materialized totals asynchronously | synchronous write-path correctness | +| Redis | low-latency ranking and rolling fraud windows | authoritative score record | +| Socket gateway | fanout to subscribed clients | transaction processing | + +### 1.4 Why the topology is split this way + +- The command side is optimized for correctness. +- The read side is optimized for speed and fanout. +- Kafka sits between them so failures on the read side do not invalidate accepted writes. +- Redis is intentionally disposable. If it is lost, the system should rebuild it. +- Durable PostgreSQL materialized totals are optional asynchronous helpers, not the source of truth. + +--- + +## 2. Detailed Score Update Flow + +### 2.1 Scenario + +User completes an action in the product. The client calls `POST /v1/actions/complete`. The system must: + +- verify the user is allowed to claim that action +- reject duplicates or replays +- update the authoritative score exactly once from the user’s perspective +- publish the resulting score change downstream without dual-write risk + +### 2.2 Step-by-step execution flow + +```text +TIME │ COMPONENT │ ACTION +─────┼──────────────────────────────┼──────────────────────────────────────────────────────────── + 1 │ Client │ User completes action + │ │ Send POST /v1/actions/complete + │ │ Body: { actionId, idempotencyKey, actionInstanceId, ... } + │ + 2 │ API Gateway │ Apply coarse request throttling / bot filtering + │ │ Forward request to score-command-api + │ + 3 │ Auth Layer │ Validate access token + │ │ Resolve authenticated userId + │ │ Optionally load session / refresh state from PostgreSQL + │ │ Reject with 401 if invalid + │ + 4 │ Request Validation │ Validate schema + │ │ Validate idempotencyKey presence and length + │ │ Validate actionId is supported + │ │ Reject with 400 on malformed input + │ + 5 │ Business Eligibility │ Load action metadata + │ │ Check action is enabled + │ │ Check caller may claim it + │ │ Reject with 403 / 422 when not allowed + │ + 6 │ PostgreSQL Transaction Start │ BEGIN + │ │ Start the authoritative mutation boundary + │ + 7 │ Idempotency Insert │ INSERT score_action_requests(...) + │ │ Unique key: (user_id, idempotency_key) + │ │ If duplicate exists, return saved prior response + │ + 8 │ Business/Fraud Checks │ Evaluate lightweight checks using request metadata + │ │ Example: invalid grant, impossible repetition, suspicious burst + │ │ No total-score row is locked here + │ + 9 │ Ledger Append │ INSERT immutable row into score_events + │ │ Return generated event_id from PostgreSQL + │ + 10 │ Outbox Write │ INSERT outbox_events(payload...) + │ │ Same transaction as the score event + │ + 11 │ Fraud Flag Persistence │ INSERT fraud_flags if the event is suspicious enough + │ │ This does not block the scoreboard path unless policy says so + │ + 12 │ Idempotent Response Save │ Save the accepted response body onto score_action_requests + │ │ So duplicate retries can return the original result verbatim + │ + 13 │ Commit │ COMMIT + │ │ Score event is now durably accepted in PostgreSQL + │ + 14 │ Response to Client │ Return acceptedEventId + scoreDelta + │ │ The projected total may appear slightly later + │ + 15 │ CDC Connector │ Detect committed outbox row + │ │ Publish event into Kafka + │ + 16 │ Projection Service │ Consume score event + │ │ Apply ZINCRBY / HINCRBY style projection in Redis + │ + 17 │ Materialization Worker │ Optionally persist durable PostgreSQL totals asynchronously + │ + 18 │ Socket Gateway │ Broadcast coalesced leaderboard:update if visible state changed + │ + 19 │ Clients │ Re-render leaderboard / user score in realtime +``` + +### 2.3 Write-flow Mermaid sequence + +```mermaid +sequenceDiagram + participant C as Client + participant G as API Gateway + participant A as score-command-api + participant P as PostgreSQL + participant D as CDC Connector + participant K as Kafka + participant S as score-projection-service + participant M as score-materialization-worker + participant R as Redis + participant W as Socket Gateway + + C->>G: POST /v1/actions/complete + G->>A: Forward request + A->>A: Authenticate + validate + authorize + A->>P: BEGIN + A->>P: INSERT score_action_requests + alt duplicate idempotency key + P-->>A: existing request row + A->>P: ROLLBACK + A-->>C: Return original saved response + else new request + A->>P: INSERT score_events + A->>P: INSERT outbox_events + opt suspicious event + A->>P: INSERT fraud_flags + end + A->>P: UPDATE score_action_requests response_body + A->>P: COMMIT + A-->>C: Accepted score response + P-->>D: Committed outbox row visible + D->>K: Publish score.user_score_updated.v1 + K->>S: Deliver event + opt durable fallback path + K->>M: Deliver event + M->>P: Upsert async materialized totals + end + S->>R: Increment leaderboard + user projection + opt visible leaderboard change + S->>W: leaderboard:update + W-->>C: Push realtime update + end + end +``` + +### 2.3.1 ASCII alternative + +```text +Client + | + v +API Gateway + | + v +score-command-api + | + +--> authenticate + validate + authorize + | + +--> PostgreSQL BEGIN + | + +--> INSERT score_action_requests + | + +--> duplicate idempotency key? + | + +--> yes: read stored response -> ROLLBACK -> return original result + | + +--> no: + | + +--> INSERT score_events (append-only) + +--> INSERT outbox_events + +--> optionally INSERT fraud_flags + +--> save response body on request row + +--> COMMIT + | + +--> return acceptedEventId + scoreDelta to client + +After commit: +PostgreSQL -> CDC Connector -> Kafka -> projection service -> Redis -> Socket Gateway -> Clients + \ + -> materialization worker -> PostgreSQL durable totals +``` + +### 2.4 Why this flow is safer than direct synchronous cache updates + +- The user gets a correct committed score as soon as PostgreSQL commits. +- Redis and sockets can lag briefly without losing the authoritative state. +- The API process never has to solve “database commit succeeded but Kafka publish failed.” +- Duplicate client retries are absorbed by the database uniqueness rule. +- The write path avoids a hot per-user total row, which is better aligned with heavy append traffic. + +--- + +## 3. Transaction + Outbox + CDC Flow + +### 3.1 Core idea + +The command API must never perform: + +- `INSERT score_events` +- then separately `publish("score-updated")` + +as two unrelated operations. + +If the process crashes between them, the database and event stream diverge. + +### 3.2 ASCII flow + +```text +┌──────────────────────────────────────────────────────────────────────┐ +│ score-command-api │ +│ │ +│ BEGIN │ +│ 1. INSERT score_action_requests │ +│ 2. INSERT score_events │ +│ 3. INSERT outbox_events │ +│ COMMIT │ +└───────────────────────────────┬──────────────────────────────────────┘ + │ committed rows only + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ CDC Connector │ +│ - watches committed outbox rows │ +│ - transforms row into event payload │ +│ - publishes to Kafka topic keyed by user_id │ +└───────────────────────────────┬──────────────────────────────────────┘ + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ Kafka │ +│ - durable event log │ +│ - replay source for projection consumers │ +│ - isolates write path from read-side failure │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### 3.3 Design rules + +- `outbox_events` must be written in the same PostgreSQL transaction as the accepted score event. +- CDC must only read committed rows. +- Kafka topic partition key should be `user_id` to preserve per-user ordering. +- Consumers must assume at-least-once delivery and deduplicate using `event_id` or another monotonic applied-position marker. +- Durable PostgreSQL totals, if present, must be maintained asynchronously and never move back into the write transaction. + +### 3.4 What the outbox payload should contain + +- event id +- event type +- user id +- action id +- score delta +- occurred at +- metadata for fraud and auditing + +--- + +## 4. Projection, Leaderboard Query, and Live Update Flow + +### 4.1 Responsibilities of the projection service + +- consume `score-events` +- update Redis leaderboard state +- update per-user read models +- compute or refresh top-10 snapshot +- decide whether a socket broadcast is necessary +- expose read APIs directly, or back a thin read-only API layer +- optionally feed a durable PostgreSQL materialization worker + +### 4.2 Projection execution flow + +```text +TIME │ COMPONENT │ ACTION +─────┼────────────────────────────┼───────────────────────────────────────────────────────────── + 1 │ Kafka │ score.user_score_updated.v1 is available + │ + 2 │ Projection Service │ Poll batch from assigned partition(s) + │ │ Buffer for 100-250ms or until N events + │ + 3 │ Projection Service │ Coalesce multiple events for same user if possible + │ │ Keep highest unapplied event_id per user in the batch + │ + 4 │ Redis Dedup Check │ Read current last_applied_event_id for user + │ │ Ignore stale or duplicate events + │ + 5 │ Redis Leaderboard Update │ ZINCRBY leaderboard:global scoreDelta userId + │ │ HINCRBY or script-update user:score:{userId} + │ │ Refresh leaderboard:top10:snapshot if needed + │ + 6 │ Fraud Window Update │ ZADD fraud:user:{userId}:events timestamp eventId + │ │ ZADD fraud:ip:{ip}:events timestamp eventId + │ + 7 │ Broadcast Decision │ Use rank-10 boundary checks as a cheap prefilter + │ │ If visibility might be affected, recompute visible top 10 + │ │ Compare with last published snapshot or snapshot hash + │ │ Emit one coalesced leaderboard:update only on real diff + │ + 8 │ Durable Materialization │ Optionally upsert PostgreSQL materialized totals in batch + │ │ using last included event_id as checkpoint + │ + 9 │ Offset Commit │ Commit Kafka offset only after required projection work succeeds +``` + +### 4.3 Projection Mermaid sequence + +```mermaid +sequenceDiagram + participant K as Kafka + participant S as score-projection-service + participant R as Redis + participant W as Socket Gateway + participant C as Client + + K->>S: Deliver batch of score events + S->>S: Coalesce by user_id / highest unapplied event_id + S->>R: Compare current last_applied_event_id + alt incoming event is stale + R-->>S: stored event id is newer or equal + S->>S: Skip event + else incoming event is newer + S->>R: ZINCRBY leaderboard:global scoreDelta + S->>R: Update user:score:{userId} + S->>R: Update fraud rolling windows + S->>R: Check user rank and current rank-10 boundary + alt cannot affect visible top 10 + S->>S: Skip socket publish + else might affect visible top 10 + S->>R: Read current top-10 snapshot + S->>S: Compare with last published snapshot/hash + alt snapshot differs + S->>W: leaderboard:update + W-->>C: Push realtime change + else snapshot unchanged + S->>S: Skip socket publish + end + end + end + S->>K: Commit offset after Redis success +``` + +### 4.3.1 ASCII alternative + +```text +Kafka + | + v +score-projection-service + | + +--> poll batch + +--> coalesce by user_id / highest event_id + +--> compare incoming event_id with stored last_applied_event_id + | + +--> stale or duplicate -> skip + | + +--> newer event: + | + +--> ZINCRBY Redis leaderboard:global + +--> update Redis user:score:{userId} + +--> update Redis fraud windows + +--> check user rank + current rank-10 boundary + | + +--> cannot affect visible top 10 -> skip socket publish + | + +--> might affect visible top 10: + | + +--> read current top-10 snapshot + +--> compare with last published snapshot/hash + | + +--> unchanged -> skip socket publish + | + +--> changed: + | + +--> emit leaderboard:update to Socket Gateway + | + +--> push update to clients + | + +--> commit Kafka offset only after Redis work succeeds +``` + +### 4.3.2 Recommended broadcast rule + +- Do not publish purely because one user score changed. +- Use rank-10 boundary checks only as a cheap prefilter. +- Final publish truth should be: + - recompute the visible top-10 payload + - compare it with the last published snapshot or a stored snapshot hash + - publish only if the visible payload changed + +Why this is the safer default: + +- It catches a user entering top 10. +- It catches a user leaving top 10. +- It catches a displaced rank-10 user. +- It catches reordering among already-visible users. +- It remains correct for micro-batches where multiple users move together. + +Recommended implementation shape: + +- Keep the last published top-10 payload or a deterministic hash of it. +- After a batch update, only perform the snapshot diff if at least one affected user: + - was already in top 10 + - moved near the rank-10 cutoff + - or could displace the current rank-10 entry +- If the prefilter says the batch cannot affect visibility, skip the socket publish path entirely. + +### 4.4 Query path + +```text +GET /v1/leaderboard/top10 + | + v +score-projection-service or thin query API + | + +--> try Redis live projection (leaderboard:top10 or leaderboard:global) + | + +--> if Redis available: + | | + | +--> return latest projected leaderboard + | + +--> if Redis unavailable: + | + +--> read PostgreSQL user_score_materialized_totals + +--> return stale/degraded response with freshness metadata +``` + +```text +GET /v1/users/:userId/score + | + v +score-projection-service or thin query API + | + +--> try Redis user projection (user:score:{userId}) + | + +--> if Redis available: + | | + | +--> return latest projected user score + rank metadata + | + +--> if Redis unavailable: + | + +--> read PostgreSQL user_score_materialized_totals + +--> compute degraded rank if supported, or return score-only fallback +``` + +### 4.5 Why batching matters + +- It reduces Redis write volume. +- It reduces socket fanout pressure. +- It keeps the system stable when many users score at once. +- The product still gets near-real-time updates because the buffer window is small. + +--- + +## 5. Fraud Detection and Abuse Evaluation + +### 5.1 Fraud goals + +The system must make malicious score inflation harder without turning the write path into a slow manual-review workflow. + +### 5.2 Fraud decision flow + +```text + Is access token valid? + │ + ┌─────────┴─────────┐ + │ │ + YES NO + │ │ + ▼ ▼ + Is action allowed? Return 401 + │ + ┌────────┴────────┐ + │ │ + YES NO + │ │ + ▼ ▼ + Is idempotency key new? Return 403 / 422 + │ + ┌──────┴──────┐ + │ │ + YES NO + │ │ + ▼ ▼ + Evaluate fraud indicators Return original response + │ + ┌────────┼─────────────────────────────────────────────────────┐ + │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ + user burst duplicate device/IP impossible rate score jump rank jump + in window reuse across users for action too large too sudden + │ │ │ │ │ + └────────┴──────────────┬──────┴────────────────┴──────────────┘ + │ + ▼ + Severity threshold exceeded? + │ + ┌────────┴────────┐ + │ │ + NO YES + │ │ + ▼ ▼ + continue normal write persist fraud_flags + and tag metadata optionally emit fraud event +``` + +### 5.3 Fraud signal sources + +- current request metadata +- action frequency per user +- action frequency by IP or device +- known cooldown violations +- impossible action sequences +- extreme score velocity + +### 5.4 Fraud state ownership + +- Redis owns rolling windows and fast signal evaluation. +- PostgreSQL owns durable `fraud_flags`. +- Admin or moderation tools should read from durable PostgreSQL state, not Redis-only signals. + +### 5.5 Fraud Mermaid view + +```mermaid +flowchart TD + Request[Incoming action completion request] + Auth[Auth + action validation] + Idempotency[Idempotency key check] + Signals[Velocity / duplicate / impossible pattern checks] + Persist[Persist fraud_flags if severe] + Accept[Continue normal score write] + Reject[Reject request] + + Request --> Auth + Auth -->|invalid| Reject + Auth -->|valid| Idempotency + Idempotency -->|duplicate request| Accept + Idempotency -->|new request| Signals + Signals -->|severe| Persist + Signals -->|normal or mild| Accept + Persist --> Accept +``` + +### 5.5.1 ASCII alternative + +```text +Incoming action completion request + | + v +Auth + action validation + | + +--> invalid --------------------> Reject + | + v +Idempotency key check + | + +--> duplicate request ----------> Return original accepted result + | + v +Velocity / duplicate / impossible-pattern checks + | + +--> severe ---------------------> Persist fraud_flags + | | + | v + +--------------------------------> Continue normal score write +``` + +--- + +## 6. Crash Recovery and Replay Flow + +### 6.1 Failure scenarios we care about + +1. API crashes before PostgreSQL commit. +2. API crashes after PostgreSQL commit but before the client receives the response. +3. CDC connector or Kafka is temporarily unavailable. +4. Projection service crashes after Redis write but before Kafka offset commit. +5. Redis is lost and must be rebuilt. +6. Durable PostgreSQL materialized totals lag behind the live Redis projection. + +### 6.2 Recovery behavior by scenario + +| Failure point | Expected behavior | Why it is safe | +| -------------------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------- | +| before DB commit | client retries | no committed score exists yet | +| after DB commit, before client sees response | client retries with same idempotency key | original response is returned from `score_action_requests` | +| CDC lag or Kafka outage | write path still commits to PostgreSQL | outbox rows remain durable until streamed | +| projection crash before offset commit | Kafka replays event | Redis last-applied-event check ignores stale duplicate | +| Redis wipe | rebuild from Kafka retention or PostgreSQL ledger/materialized totals | Redis is derived state | +| materialized totals lag | serve Redis as primary; use PG only as degraded fallback | materialized totals are intentionally asynchronous | + +### 6.3 Replay flow ASCII + +```text +┌──────────────────────────────────────────────────────────────────────┐ +│ Event consumed from Kafka │ +└───────────────────────────────┬──────────────────────────────────────┘ + ▼ + ┌───────────────────────────────┐ +│ Apply Redis projection │ +│ event_id = 9455331 │ + └───────────────┬───────────────┘ + │ + crash before offset commit? + │ + ┌─────────┴─────────┐ + │ │ + YES NO + │ │ + ▼ ▼ + process restarts commit offset normally + │ + ▼ + Kafka redelivers event + │ + ▼ + Redis already has last_applied_event_id >= incoming event_id + │ + ▼ + Ignore stale duplicate safely + │ + ▼ + Commit offset +``` + +### 6.4 Replay Mermaid sequence + +```mermaid +sequenceDiagram + participant K as Kafka + participant S as score-projection-service + participant R as Redis + + K->>S: Deliver event(event_id=9455331) + S->>R: Apply projection(event_id=9455331) + alt service crashes before offset commit + Note over S: process exits unexpectedly + K->>S: Replay same event after restart + S->>R: Read stored last_applied_event_id + R-->>S: stored event_id already 9455331 + S->>S: Ignore stale duplicate + S->>K: Commit offset + else normal execution + S->>K: Commit offset + end +``` + +### 6.4.1 ASCII alternative + +```text +Kafka delivers event(event_id=9455331) + | + v +score-projection-service applies Redis projection + | + +--> crash before offset commit? + | + +--> no -> commit offset + | + +--> yes -> process restarts + | + v + Kafka redelivers same event + | + v + projection service reads Redis last_applied_event_id + | + +--> stored event_id already 9455331 + | + v + ignore stale duplicate safely + | + v + commit offset +``` + +### 6.5 Redis rebuild strategy + +```text +Option A: Kafka retained history available + Kafka replay -> projection service -> rebuild Redis + +Option B: durable PostgreSQL materialized totals available + load materialized totals -> replay later events only -> rebuild Redis + +Option C: Kafka history insufficient and no recent materialized seed + PostgreSQL score_events backfill job -> synthetic replay -> rebuild Redis +``` + +This rebuild path should be documented before production launch. Otherwise Redis stops being safely disposable in practice. + +--- + +## 7. Authentication and Session Flow + +### 7.1 Why PostgreSQL-backed session state is included + +The user asked for a design that highlights database skill. Storing refresh/session state in PostgreSQL shows: + +- revocation capability +- device and IP correlation +- auditability for security incidents + +### 7.2 Auth flow + +```text +┌──────────────────────────────────────────────────────────────────────┐ +│ 1. User authenticates │ +└───────────────────────────────┬──────────────────────────────────────┘ + ▼ + ┌────────────────────────────────┐ + │ Server issues short-lived JWT │ + │ and stores refresh/session row │ + │ in PostgreSQL user_sessions │ + └───────────────┬────────────────┘ + │ + ┌────────────────┴────────────────┐ + │ │ + ▼ ▼ + ┌─────────────────────┐ ┌─────────────────────────┐ + │ HTTP score mutation │ │ WebSocket connection │ + │ Authorization: Bearer JWT │ token in header/query │ + └────────────┬────────┘ └────────────┬────────────┘ + │ │ + ▼ ▼ + ┌─────────────────────┐ ┌─────────────────────────┐ + │ Verify JWT │ │ Verify JWT │ + │ Check revocation if │ │ Reject unauthorized │ + │ policy requires it │ │ connections │ + └─────────────────────┘ └─────────────────────────┘ +``` + +### 7.3 Session-related checks + +- rejected if token invalid or expired +- optionally rejected if backing session is revoked +- action request stores session, device, and IP metadata for later analysis + +--- + +## 8. Error Handling and Consistency Boundaries + +### 8.1 Error handling map + +```text + Incoming request + │ + ┌───────────────────────┼────────────────────────┐ + │ │ │ + ▼ ▼ ▼ + schema invalid token invalid forbidden action + │ │ │ + ▼ ▼ ▼ + return 400 return 401 return 403 / 422 + │ + ▼ + database transaction starts + │ + duplicate idempotency key detected? + │ + ┌───────────┴───────────┐ + │ │ + YES NO + │ │ + ▼ ▼ + return stored original response continue write +``` + +### 8.2 Consistency notes + +- The success response from `POST /v1/actions/complete` is authoritative. +- The success response confirms event acceptance, not immediate query-side total score. +- `GET /v1/leaderboard/top10` is eventually consistent. +- WebSocket updates are projection-driven, not transaction-driven. +- The product should expect small propagation delay under normal operation. +- Durable PostgreSQL materialized totals can lag Redis and should be treated as fallback, not primary live ranking state. + +### 8.3 Client-facing implications + +- If a client cares about “my new score right now,” it should use the write response. +- If a client needs immediate UI feedback, it can optimistically add `scoreDelta` locally until the projection catches up. +- If a client cares about “global top 10 right now,” it should use the projected read API or realtime update stream. + +--- + +## 9. Deployment and Scaling Architecture + +### 9.1 Deployment topology + +```text + ┌──────────────────┐ + │ Internet Users │ + └────────┬─────────┘ + ▼ + ┌────────────────────────────┐ + │ API Gateway / Load Balancer│ + └──────────────┬─────────────┘ + │ + ┌─────────────────────────────┼─────────────────────────────┬──────────────────────┐ + │ │ │ │ + ▼ ▼ ▼ ▼ +┌──────────────────┐ ┌────────────────────┐ ┌──────────────────┐ ┌──────────────────┐ +│ command-api pod 1│ │ query/projection │ │ socket gateway 1 │ │ socket gateway N │ +│ command-api pod 2│ │ pod 1 │ │ socket gateway 2 │ └──────────────────┘ +│ command-api pod N│ │ pod N │ └─────────▲────────┘ +└─────────┬────────┘ └─────────┬──────────┘ │ + │ │ │ leaderboard fanout + ▼ │ │ + ┌──────────────┐ │ │ + │ PostgreSQL │ │ │ + │ primary │ │ │ + │ + replicas │ │ │ + └──────┬───────┘ │ │ + │ │ │ + │ committed outbox rows │ │ + ▼ │ │ + ┌──────────────┐ │ │ + │ CDC Connector│ │ │ + │ Debezium │ │ │ + └──────┬───────┘ │ │ + ▼ │ │ + ┌──────────────┐ │ │ + │ Kafka │──────────────────┘ │ + │ cluster │───────────────────────────────────────────────┘ + └──────┬───────┘ + │ + ├──> consumed by query/projection pods (scale by Kafka partitions) + │ + └──> consumed by materialization worker + | + v + ┌──────────────┐ + │ PostgreSQL │ + │ materialized │ + │ totals │ + └──────┬───────┘ + │ + v + ┌──────────────┐ + │ backups / DR │ + └──────────────┘ + +query/projection pods + | + +--> serve GET read APIs + +--> update Redis live query model + +--> publish leaderboard fanout to socket gateways + +Redis live query model + | + +--> leaderboard keys + +--> user score projections + +--> fraud windows +``` + +### 9.2 Primary scaling dimensions + +- `score-command-api` + - scale by incoming write request volume +- projection consumers + - scale by Kafka partition count and event throughput +- materialization workers + - scale by replay and durable-fallback throughput needs +- socket gateways + - scale by concurrent live connections +- PostgreSQL + - scale carefully; keep transactions append-heavy on the write path and batch materialized updates +- Redis + - scale by read QPS, top-N traffic, and socket fanout support + +### 9.3 Operational note + +The write path and read path can scale independently. That is one of the main reasons to keep them separated. + +--- + +## 10. Suggested Monitoring and Alerts + +### 10.1 Key metrics + +- API latency for `POST /v1/actions/complete` +- database transaction latency +- idempotency replay rate +- outbox backlog growth +- CDC lag +- Kafka consumer lag +- Redis update latency +- leaderboard projection freshness +- socket broadcast latency +- fraud flag rate + +### 10.2 Alert examples + +| Metric | Threshold example | Response | +| ----------------------- | ----------------------------- | ----------------------------------------- | +| command API p99 latency | > 500ms sustained | inspect DB contention, slow queries | +| outbox backlog | growing for 5+ minutes | inspect CDC connector | +| Kafka consumer lag | above agreed SLO | scale projection workers or inspect Redis | +| Redis latency | > 50-100ms sustained | inspect hot keys / cluster pressure | +| projection freshness | older than live-update target | degrade banner or alert on-call | +| fraud rate spike | above baseline | inspect abuse campaign or client bug | + +### 10.3 Runbook expectations + +Before production, the team should have runbooks for: + +- Kafka lag spike +- Redis rebuild +- projection consumer poison event +- revoked-session incident +- admin score correction or reversal + +--- + +## 11. Final Implementation Guidance + +If the backend team follows this document: + +- PostgreSQL remains the single source of truth. +- the write path stays correct under retries and concurrency. +- downstream failure does not lose accepted scores. +- Redis stays fast but disposable. +- leaderboard updates remain near-real-time without coupling correctness to socket fanout. + +That is the core difference between an interview-level CRUD answer and a production-oriented scoreboard design. diff --git a/src/problem6/IMPROVEMENTS.md b/src/problem6/IMPROVEMENTS.md new file mode 100644 index 0000000000..c20ed485e4 --- /dev/null +++ b/src/problem6/IMPROVEMENTS.md @@ -0,0 +1,628 @@ +# Problem 6 Improvements + +This document describes how to evolve the base architecture in `README.md` and `ARCHITECTURE.md` after the first correct implementation is in place. + +The main principle is: + +- do not compromise correctness on the write path +- add scale and operational sophistication in stages +- introduce complexity only when the product actually needs it + +--- + +## 1. Improvement Strategy Overview + +The proposed architecture is already strong, but it should not be shipped as "everything on day one" unless the product really needs it. + +### Improvement roadmap + +```text +Stage 1: Correctness Core + PostgreSQL + idempotent API + append-only ledger + outbox + Redis live projection + simple live fanout + +Stage 2: Replayable Streaming Read Side + CDC + Kafka + projection workers + Redis event-id dedupe + durable PG materialization + replay tooling + +Stage 3: Operational Maturity + multi-node socket fanout + richer fraud scoring + admin workflows + DR / archival +``` + +### Decision rule + +```text +If product traffic is modest and the team is small: + start with Stage 1 + +If multiple read-side consumers or rebuildability matter: + add Stage 2 + +If uptime, compliance, moderation, and global scale matter: + add Stage 3 +``` + +--- + +## 2. Stage 1 Improvements: Correctness First + +Stage 1 is the minimum strong production shape for a real scoreboard system. + +### What Stage 1 should include + +- PostgreSQL as source of truth +- `POST /v1/actions/complete` with idempotency keys +- immutable `score_events` ledger +- transactional outbox table +- Redis live leaderboard projection and per-user score cache +- single-region socket fanout + +### Why this stage matters + +- prevents score duplication on retries +- keeps the write path append-only and light +- creates a durable audit trail +- leaves room to add streaming later without redesigning the core write path + +### Example: retry-safe request behavior + +Client sends: + +```json +{ + "actionId": "daily-login", + "idempotencyKey": "0f83c365-f1c5-4f1c-a17d-4227e9433f89" +} +``` + +If the client times out and retries with the same key: + +- the API must not add score twice +- the API should return the original accepted response body + +### Stage 1 flow + +```text +Client + | + v +score-command-api + | + +--> validate token + +--> validate action + +--> INSERT request(idempotency key) + +--> INSERT score_events + +--> INSERT outbox_events + +--> COMMIT + | + +--> projection catches up asynchronously + | + +--> emit single-region live update +``` + +### Suggested Stage 1 additions + +- add deterministic tie-breakers early +- store the accepted response body on the request table +- record `deviceId`, `sessionId`, and `ip` with every accepted score change + +--- + +## 3. Stage 2 Improvements: Replayable Read Side + +Stage 2 is where the system becomes much easier to recover and evolve. + +### What Stage 2 should include + +- CDC from PostgreSQL outbox into Kafka +- dedicated projection consumers +- Redis dedupe checks using `last_applied_event_id` +- durable PostgreSQL materialized totals for replay seeding and degraded fallback +- replay/backfill tooling +- dead-letter handling for poison events + +### Why this stage matters + +- separates the write path from read-side failures +- allows Redis rebuild without re-accepting writes +- supports more than one downstream consumer +- supports richer analytics and fraud consumers later + +### Example: projection idempotency + +Kafka delivers the same event twice: + +```json +{ + "eventId": 9455331, + "userId": "user-123", + "scoreDelta": 50 +} +``` + +If Redis already stores `lastAppliedEventId = 9455331` for that user, the consumer should: + +- skip the duplicate +- not re-emit a visible leaderboard update +- commit the Kafka offset after the check + +### Stage 2 flow + +```text +PostgreSQL commit + | + v +outbox row committed + | + v +CDC connector + | + v +Kafka topic score-events + | + v +projection service + | + +--> compare eventId with Redis last_applied_event_id + +--> ZINCRBY leaderboard keys + +--> update user score document + +--> optionally upsert PostgreSQL materialized totals + +--> emit socket update if visible state changed + +--> commit offset +``` + +### Recommended Stage 2 operational additions + +- dead-letter topic for malformed or poison events +- replay command that rebuilds one scope at a time +- freshness metric for projected leaderboard lag + +### Example replay command shape + +```text +rebuild leaderboard scope: + source = Kafka retained topic + target = Redis keys for global leaderboard only + mode = stop writes? no + result = rebuild derived state without touching PostgreSQL truth +``` + +--- + +## 4. Stage 3 Improvements: Operational Maturity + +Stage 3 is appropriate when the scoreboard becomes a business-critical or abuse-sensitive system. + +### What Stage 3 should include + +- multi-node socket fanout with Redis or broker-backed adapter +- richer fraud scoring model +- admin correction and reversal workflows +- long-retention archival for rebuilds +- disaster recovery and region failover planning + +### Why this stage matters + +- scaling live connections is a different problem from scaling writes +- moderation and support teams eventually need safe correction tools +- retention and recovery matter more once the leaderboard becomes user-visible business state + +### Stage 3 maturity ladder + +```text +Single node live updates + -> +multiple socket nodes with shared fanout bus + -> +admin score review and reversal tooling + -> +long-retention replay support + -> +regional failover / DR plan +``` + +--- + +## 5. Product and Ranking Improvements + +The initial problem only asks for top 10 live scores. Real products usually need more. + +### 5.1 Deterministic tie-breakers + +Do not leave equal scores unordered. + +Recommended order: + +1. `total_score DESC` +2. `reached_score_at ASC` +3. `action_count DESC` +4. `user_id ASC` + +### Example + +```text +User A score = 5000, reached at 10:00, action_count = 70 +User B score = 5000, reached at 10:05, action_count = 80 + +Result: + User A ranks higher because the same score was reached earlier. +``` + +### 5.2 Scoped leaderboards + +Recommended future scopes: + +- daily leaderboard +- weekly leaderboard +- monthly leaderboard +- friend leaderboard +- region leaderboard + +### Example API shapes + +```text +GET /v1/leaderboard/top10?period=day +GET /v1/leaderboard/top10?period=week +GET /v1/leaderboard/top10?scope=friends +GET /v1/leaderboard/rank-neighbors?userId=user-123&window=5 +``` + +### Scoped leaderboard model + +```text +global leaderboard + | + +--> all-time + +--> daily + +--> weekly + +--> monthly + +--> friends + +--> region +``` + +### 5.3 Rank-neighbor queries + +Top 10 is not enough for most users. + +Example: + +```text +User rank = 248 +Need to show: + ranks 243-253 +instead of only the global top 10 +``` + +This reduces the product problem where most users never see themselves. + +--- + +## 6. Admin Correction and Reversal Workflow + +Eventually the team needs a safe way to correct bad score state. + +### Recommended model + +- never silently overwrite score history +- create immutable reversal or adjustment events +- restrict adjustment endpoints to admin roles +- always log the reason and operator identity + +### Example use cases + +- fraud investigation confirms a boosted account +- a production bug double-awarded points +- a moderator restores score after incorrect abuse enforcement + +### Recommended workflow + +```text +Admin identifies incorrect score event + | + v +Create adjustment / reversal request + | + v +Admin API validates permissions + | + v +PostgreSQL transaction + | + +--> append adjustment event + +--> write outbox event + +--> write audit trail + | + v +CDC -> Kafka -> projection update -> clients see corrected leaderboard + | + +--> async PostgreSQL materialized totals catch up too +``` + +### Example adjustment payload + +```json +{ + "userId": "user-123", + "adjustmentType": "reversal", + "scoreDelta": -50, + "reason": "duplicate daily-login event from incident INC-1042" +} +``` + +--- + +## 7. Operational Improvements + +### 7.1 Monitoring additions + +Track at minimum: + +- command API latency +- PostgreSQL transaction latency +- idempotency replay rate +- outbox backlog +- CDC lag +- Kafka consumer lag +- Redis update latency +- socket fanout latency +- fraud flag rate + +### 7.2 Alert examples + +| Problem | Example symptom | Possible action | +| --- | --- | --- | +| Outbox stuck | `outbox_events` pending rows keep growing | inspect CDC connector and connector offsets | +| Projection lag | top-10 snapshot is 2 minutes behind | scale consumers or inspect Redis slowdown | +| Redis pressure | p95 Redis write latency spikes | reduce batch size churn, inspect hot keys | +| Socket overload | client reconnects spike | scale socket gateways, inspect pub/sub bus | + +### 7.3 Replay/backfill tooling + +The team should be able to rebuild: + +- entire global leaderboard +- one scoped leaderboard only +- one user projection only + +### Replay tooling flow + +```text +Need rebuild? + | + +--> one user only + | | + | +--> read score_events for that user + | +--> rebuild Redis user document + | + +--> one leaderboard scope + | | + | +--> replay relevant Kafka events or PostgreSQL backfill job + | +--> refresh snapshot keys only for that scope + | + +--> full rebuild + | + +--> stream historical events + +--> rebuild all derived Redis state +``` + +### 7.4 Backpressure controls + +If Redis becomes slow: + +- reduce socket emission frequency +- enlarge micro-batch window temporarily +- prioritize leaderboard snapshots over lower-priority auxiliary keys +- if PostgreSQL materialized totals are updated in the same worker pool, batch them separately from latency-sensitive Redis work +- pause or isolate non-critical derived consumers + +--- + +## 8. Security Improvements + +### 8.1 Signed action grants + +If the client is not fully trusted, the server can issue a short-lived action grant before completion. + +Flow: + +```text +Server issues action grant + | + v +Client completes work and submits grant + | + v +Command API verifies: + - signature + - expiry + - userId match + - actionId match +``` + +This makes arbitrary client-crafted score requests harder. + +### 8.2 Session and device correlation + +Keep the following with accepted actions: + +- `sessionId` +- `deviceId` +- `ip` +- `userAgent` + +### Example fraud use + +```text +Five accounts + | + +--> same IP + +--> same device fingerprint + +--> same high-value action every 3 seconds + +Result: + cluster should be flagged for analyst review +``` + +### 8.3 Permission separation + +Separate: + +- player permissions +- moderator review permissions +- admin score-adjust permissions + +Do not reuse the normal player write path for administrative changes. + +--- + +## 9. Data Lifecycle Improvements + +### 9.1 Table partitioning + +Good partition candidates: + +- `score_events` +- `fraud_flags` +- possibly `outbox_events` if volume is very high + +### Example lifecycle + +```text +Current month partitions + | + +--> score_events_2026_04 + +--> score_events_2026_05 + +--> score_events_2026_06 + +Older partitions + | + +--> compressed / archived + +--> still queryable for audits if required +``` + +### 9.2 Retention policy + +- keep short-term request dedupe rows long enough for retry windows +- keep ledger data much longer than cache data +- do not treat Redis as long-term storage + +### 9.3 Compliance-sensitive storage + +Only store what Redis needs for realtime decisions. + +Prefer PostgreSQL for: + +- durable review data +- operator actions +- security-sensitive metadata + +--- + +## 10. Realtime Delivery Improvements + +### 10.1 Multi-node socket fanout + +Single-node sockets work early, but not forever. + +Recommended evolution: + +```text +Clients + | + v +Socket Gateway 1 ----\ +Socket Gateway 2 -----+--> shared fanout bus (Redis adapter / broker) +Socket Gateway N ----/ +``` + +This ensures a projection update can reach clients connected to any gateway instance. + +### 10.2 Coalesced update strategy + +Instead of one socket message per raw score event: + +- buffer for 100-250ms +- combine changes by user or by top-10 diff +- emit one coalesced update + +### Example + +```text +Without coalescing: + 300 score events in 1 second + -> 300 Redis writes + -> 300 socket pushes + +With coalescing: + 300 score events in 1 second + -> 20-40 projection batches + -> 20-40 socket pushes +``` + +### 10.3 SSE vs WebSocket + +If the product only needs one-way live scoreboard updates, SSE may be simpler. + +Use WebSockets when: + +- bidirectional client/server realtime traffic is needed +- the team already has socket infrastructure +- reconnect, rooms, or richer event semantics are already expected + +Use SSE when: + +- only server-to-client streaming is needed +- simplicity is more valuable than socket flexibility + +--- + +## 11. Anti-Fraud Evolution Path + +Fraud systems usually evolve in stages too. + +### Maturity ladder + +```text +Stage A: rule-based checks + - rate limits + - duplicate detection + - impossible cooldown checks + +Stage B: clustered signals + - IP clusters + - device reuse + - score velocity groups + +Stage C: analyst-assisted models + - review queue + - operator tooling + - model feedback from confirmed incidents +``` + +### Practical recommendation + +Start with clear, explainable rules. Only add more advanced scoring once you have: + +- enough volume +- enough labeled abuse cases +- operator processes to review and act on flags + +--- + +## 12. Recommended Priority Order + +If the team can only do a few things next, prioritize in this order: + +1. Store and replay original idempotent responses. +2. Add deterministic tie-breakers. +3. Add replay/backfill tooling for derived Redis state. +4. Add admin adjustment and reversal workflow. +5. Add multi-node socket fanout and richer fraud tooling only when the product needs them. + +### Final recommendation + +The best improvement philosophy for this system is: + +- correctness first +- replayability second +- operational maturity third + +That keeps the architecture strong without turning it into unnecessary platform complexity too early. diff --git a/src/problem6/README.md b/src/problem6/README.md new file mode 100644 index 0000000000..b4f11cada9 --- /dev/null +++ b/src/problem6/README.md @@ -0,0 +1,444 @@ +# Problem 6: Real-Time Scoreboard System + +This package implements the requested Problem 6 deliverable as an implementation-ready backend specification for a secure, real-time scoreboard. + +The design is intentionally centered on PostgreSQL correctness first, with Kafka and Redis used as downstream infrastructure for replayable read models and low-latency leaderboard delivery. + +This version uses an append-only write model: + +- the authoritative write path stores accepted score events +- Redis maintains the live query model +- PostgreSQL can keep an asynchronous durable materialized total for warm starts and degraded fallback +- the write path does not synchronously update a current-total aggregate row + +Related documents: + +- `ARCHITECTURE.md` for flow diagrams +- `IMPROVEMENTS.md` for rollout and follow-up ideas +- `REVIEW.md` for plan review, best-practice assessment, and company-pattern research +- `schema.sql` for a concrete PostgreSQL schema draft + +## Goals + +- Keep score writes correct under retries and concurrency. +- Keep the write path append-only and lightweight under heavy score traffic. +- Deliver near-real-time top-10 leaderboard updates. +- Prevent unauthorized or replayed score inflation. +- Make the read model disposable and rebuildable. +- Show clear database engineering depth: locking, idempotency, auditability, and recovery. + +## Non-Goals + +- Defining the product-specific action catalog or game rules in detail. +- Requiring exactly-once delivery end to end. The design targets at-least-once delivery with idempotent consumers. +- Making Redis or WebSocket state authoritative. + +## System Topology + +### Command side + +- `score-command-api` + - Authenticates the caller. + - Validates the requested action. + - Appends the authoritative accepted score event in PostgreSQL. + - Writes an outbox event in the same transaction. + +### Data platform + +- `postgres` + - Source of truth for users, sessions, action requests, score ledger, fraud flags, outbox rows, and optional durable materialized totals. +- `cdc-connector` + - Debezium or equivalent connector that reads committed outbox rows. +- `kafka` + - Durable event transport and replay source for projection services. + +### Read side + +- `score-projection-service` + - Consumes score events from Kafka. + - Updates Redis leaderboard and score projections. + - Serves read APIs directly or powers a thin query API layer. + - Triggers live leaderboard fanout only when the visible top-10 payload actually changes. +- `score-materialization-worker` + - Periodically or continuously persists durable PostgreSQL materialized totals from the event stream for warm starts, bounded replay, or degraded read fallback. +- `redis` + - Read-optimized cache and ranking store. +- `socket gateway` + - Fanout layer for clients subscribed to leaderboard updates. + +## Write API Contract + +### `POST /v1/actions/complete` + +Records a completed action and increases the user score if the request is valid and not already processed. + +#### Request + +```json +{ + "actionId": "daily-login", + "idempotencyKey": "45e31d35-4db2-40ae-9a7b-b63f6b151a11", + "actionInstanceId": "quest-run-2026-04-17-001", + "deviceId": "ios-14f02be", + "clientTimestamp": "2026-04-17T11:12:03.122Z" +} +``` + +#### Authentication + +- Access token must identify the authenticated user. +- Refresh token or session state should be revocable server-side through `user_sessions` in PostgreSQL. +- The API must reject requests where the caller is not allowed to claim the requested action. + +#### Command-side steps + +1. Authenticate the caller and resolve `userId`. +2. Validate request shape and action eligibility. +3. Begin a PostgreSQL transaction. +4. Insert into `score_action_requests` using `(user_id, idempotency_key)` uniqueness. +5. If the key already exists: + - return the previously saved response body + - do not mutate score again +6. Insert an immutable ledger row into `score_events` and return its `event_id`. +7. Insert one or more `outbox_events` rows in the same transaction. +8. Insert `fraud_flags` if the request trips suspicious heuristics. +9. Persist the accepted API response back onto the request row. +10. Commit and return acceptance of the event. + +Important consequence: + +- the command API acknowledges the accepted score event +- it does not depend on Redis being updated first +- it does not synchronously compute the final query-side total score + +#### Success response + +```json +{ + "requestId": "8d2d0f2c-6026-4c3e-9da1-91d42221f31c", + "acceptedEventId": 9455331, + "userId": "user-123", + "actionId": "daily-login", + "scoreDelta": 50, + "acceptedAt": "2026-04-17T11:12:03.240Z", + "projectionStatus": "pending", + "consistency": "event_accepted" +} +``` + +This response confirms that the score event is durably accepted. The projected total score might appear slightly later in Redis and WebSocket updates. + +#### Error contract + +- `400 Bad Request` + - malformed body + - invalid idempotency key + - unsupported action +- `401 Unauthorized` + - missing or invalid access token +- `403 Forbidden` + - user is not allowed to claim the action +- `409 Conflict` + - conflicting idempotency key reuse with different parameters +- `422 Unprocessable Entity` + - action already consumed or business rule violated +- `429 Too Many Requests` + - abuse or rate limit enforcement + +## Query API Contract + +### `GET /v1/leaderboard/top10` + +Returns the latest projected leaderboard snapshot from Redis. + +```json +{ + "lastAppliedEventId": 99122, + "generatedAt": "2026-04-17T11:12:03.390Z", + "isProjectionStale": false, + "entries": [ + { + "rank": 1, + "userId": "user-742", + "displayName": "Sam", + "score": 21250 + } + ] +} +``` + +### `GET /v1/users/:userId/score` + +Returns the user-facing projected score state. + +```json +{ + "userId": "user-123", + "score": 1500, + "rank": 18, + "lastAppliedEventId": 9455331, + "updatedAt": "2026-04-17T11:12:03.390Z" +} +``` + +## Realtime Contract + +### `wss://api.scoreboard.service/v1/leaderboard/live` + +Clients subscribe to push updates after projection, not directly from the command transaction. + +#### Initial payload + +```json +{ + "event": "leaderboard:initial", + "data": { + "lastAppliedEventId": 99122, + "generatedAt": "2026-04-17T11:12:03.390Z", + "entries": [] + } +} +``` + +#### Incremental update payload + +```json +{ + "event": "leaderboard:update", + "data": { + "leaderboardEventId": 99123, + "lastAppliedEventId": 9455331, + "generatedAt": "2026-04-17T11:12:03.440Z", + "entries": [ + { + "rank": 1, + "userId": "user-742", + "displayName": "Sam", + "score": 21250 + } + ] + } +} +``` + +Recommended delivery semantics: + +- `leaderboard:update` should represent the latest visible top-10 snapshot after projection. +- The projection service may use rank-10 boundary checks as a cheap prefilter, but the publish decision should come from diffing the visible snapshot against the last published snapshot or snapshot hash. + +## Consistency Model + +- PostgreSQL is authoritative for accepted writes. +- Redis is eventually consistent and may lag behind accepted writes. +- Clients should treat the `POST /v1/actions/complete` response as authoritative confirmation that the event was accepted. +- Clients should treat total score, rank, and top-10 membership as projection-based state. +- Leaderboard queries and socket updates are projection-based and may trail by milliseconds to seconds under load. +- If the product needs an immediate UI update, it should use optimistic rendering from `scoreDelta` until the projection catches up. + +## PostgreSQL Data Model + +Core tables: + +- `users` +- `actions` +- `user_sessions` +- `score_action_requests` +- `score_events` +- `outbox_events` +- `fraud_flags` +- `user_score_materialized_totals` +- `projection_checkpoints` + +Important database decisions: + +- Idempotency is enforced with a unique constraint, not only application logic. +- If `actionInstanceId` is supplied, a per-user/per-action uniqueness rule can also prevent double-claiming the same action occurrence with a different idempotency key. +- `score_events` is immutable and append-only. +- `score_events.score_delta` is constrained to positive values in the current additive-only design. +- `outbox_events` is written in the same transaction as the authoritative mutation. +- `user_score_materialized_totals` is asynchronous and should not be part of the write transaction. +- Durable fallback totals are a materialized view, not the authoritative write model. +- Fraud review state is durable in PostgreSQL even if Redis windows are lost. + +Important design distinction: + +- `score_events` is the source of truth +- Redis is the live query model +- `user_score_materialized_totals` is a durable asynchronous materialized view for warm starts, bounded replay, or degraded fallback +- if aggregate replay ever becomes expensive, snapshot events or snapshot rows can be added later as an optimization + +## Why No Synchronous Total Row + +For a write-heavy scoreboard, continuously updating a per-user total row can create unnecessary write amplification and contention on a hot aggregate. + +This design instead prefers: + +- append-only `score_events` on the write path +- Redis materialized views for live totals and rankings +- asynchronous PostgreSQL materialized totals for replay seeding or degraded fallback + +This is a better fit when: + +- score updates are additive +- the product accepts eventual consistency for rank and total reads +- the API can acknowledge event acceptance without promising that the projection is already caught up + +See `schema.sql` for a concrete table/index draft. + +## Kafka Event Model + +Topic: + +- `score-events` + +Partition key: + +- `user_id` + +Event example: + +```json +{ + "eventId": 9455331, + "eventType": "score.user_score_updated.v1", + "userId": "user-123", + "actionId": "daily-login", + "scoreDelta": 50, + "occurredAt": "2026-04-17T11:12:03.240Z", + "request": { + "idempotencyKey": "45e31d35-4db2-40ae-9a7b-b63f6b151a11", + "sessionId": "c9ecdd67-c52a-4d30-bb41-0bf613fd27da", + "deviceId": "ios-14f02be", + "ip": "203.0.113.10" + } +} +``` + +Event rules: + +- Each consumer must treat the stream as at-least-once. +- Consumers must ignore stale or duplicate event IDs that were already applied for the same user. +- Schema evolution should use explicit versioned event types. + +## Redis Read Model + +Recommended keys: + +- `leaderboard:global` + - sorted set keyed by `user_id` + - updated with `ZINCRBY` using `score_delta` +- `leaderboard:top10:snapshot` + - optional cached JSON snapshot for fast reads +- `user:score:{userId}` + - hash or JSON blob with score, rank, `last_applied_event_id`, and update time +- `fraud:user:{userId}:events` + - sorted set scored by event timestamp +- `fraud:ip:{ip}:events` + - sorted set for burst detection by IP + +Recommendation: + +- do not rely on TTL expiry for the core leaderboard sorted set or per-user live projection +- let the projection service keep these keys current +- if you use TTL at all, use it only on secondary convenience snapshots such as `leaderboard:top10:snapshot` + +Key query patterns: + +- `ZREVRANGE leaderboard:global 0 9 WITHSCORES` +- `ZREVRANK leaderboard:global {userId}` + +## Projection Service + +The projection service owns Redis updates and real-time fanout. + +Responsibilities: + +- Consume `score-events` from Kafka. +- Apply only unseen `event_id` values for each user. +- Increment the global leaderboard sorted set. +- Maintain per-user score documents with the last applied event ID. +- Use rank-10 boundary checks as a prefilter for realtime fanout work. +- Recompute and diff the visible top-10 snapshot before publishing `leaderboard:update`. +- Maintain short-lived Redis fraud windows for anomaly detection. +- Optionally feed or coordinate the durable PostgreSQL materialization worker. + +Recommended broadcast rule: + +- do not publish purely because one user score changed +- after applying a batch, first check whether any affected user could impact the visible top 10 +- only then read the current top-10 snapshot and compare it with the last published snapshot or snapshot hash +- publish realtime updates only when the visible payload actually changed + +Recommended batching: + +- Buffer events per partition for `100-250ms`, or +- flush after `N` events + +This avoids one Redis write plus one socket message per raw score event during spikes. + +## Durable PostgreSQL Materialization + +For heavy-write systems, a durable PostgreSQL fallback can exist without returning to a synchronous aggregate row on the write path. + +Recommended model: + +- `user_score_materialized_totals` is updated asynchronously by a separate materialization worker in the default architecture. +- Smaller deployments may choose to fold this into the projection service later, but that is an implementation simplification, not the current recommended topology. +- each row stores the latest total score, action count, and `last_event_id` included in that materialization + +Use cases: + +- warm-start Redis after a restart +- bound replay cost by starting from the last materialized event +- serve degraded reads if Redis is unavailable + +This is not a TTL-driven cache refresh mechanism. It is a durable asynchronous materialized view. + +## Fraud and Abuse Controls + +Fraud protection should be layered: + +- authenticated users only +- action allow-list with server-owned score values +- idempotency key requirement for every write +- session/device/IP metadata capture +- Redis sliding windows for burst and velocity detection +- durable PostgreSQL fraud flags for analyst review +- admin tools for manual freeze, reversal, or account review + +Redis should provide fast signals, not final truth. Durable review actions belong in PostgreSQL. + +## Recovery Model + +- Kafka offsets are committed only after Redis projection succeeds. +- Redis stores the latest applied `event_id` per user. +- Replayed events with older or equal IDs are ignored for that user. +- Projection handlers should persist a checkpoint or last-seen event ID so restart/resume is exact. +- If Redis is lost, rebuild it from Kafka retention or from PostgreSQL `score_events`. +- If full replay is expensive, seed Redis from `user_score_materialized_totals` and replay only later events. +- If Kafka retention is not long enough for rebuild, back up the ledger to long-retention storage or use PostgreSQL backfill jobs. + +## Operational Expectations + +- Metrics + - request success rate + - idempotency replay rate + - transaction latency + - Kafka consumer lag + - projection freshness + - socket fanout latency + - fraud flag rate +- Alerts + - projection lag threshold exceeded + - outbox growth without downstream delivery + - Redis rebuild active + - dead-letter queue or poison event growth + +## Why This Is Strong + +- The write path is correct even when downstream systems fail. +- The write path is append-only and lighter under heavy score traffic. +- The read side is fast and disposable. +- Recovery is explicit instead of implied. +- Idempotency remains first-class, while the system avoids a hot per-user total row on the write path. +- The design can start small and grow into a larger streaming platform without changing the core correctness model. diff --git a/src/problem6/schema.sql b/src/problem6/schema.sql new file mode 100644 index 0000000000..8f81e3b44e --- /dev/null +++ b/src/problem6/schema.sql @@ -0,0 +1,133 @@ +BEGIN; + +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, + external_id VARCHAR(128) NOT NULL UNIQUE, + display_name VARCHAR(120) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE actions ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(64) NOT NULL UNIQUE, + score_delta INTEGER NOT NULL CHECK (score_delta > 0), + cooldown_seconds INTEGER NOT NULL DEFAULT 0 CHECK (cooldown_seconds >= 0), + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE user_sessions ( + session_id UUID PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id), + refresh_token_hash TEXT NOT NULL, + device_id VARCHAR(128), + ip INET, + user_agent TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + last_seen_at TIMESTAMPTZ +); + +CREATE TABLE score_action_requests ( + request_id UUID PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id), + action_id BIGINT NOT NULL REFERENCES actions(id), + session_id UUID REFERENCES user_sessions(session_id), + idempotency_key VARCHAR(255) NOT NULL, + action_instance_id VARCHAR(128), + request_payload JSONB NOT NULL, + response_code INTEGER, + response_body JSONB, + requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ, + UNIQUE (user_id, idempotency_key) +); + +CREATE TABLE score_events ( + event_id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id), + action_id BIGINT NOT NULL REFERENCES actions(id), + request_id UUID NOT NULL REFERENCES score_action_requests(request_id), + score_delta INTEGER NOT NULL CHECK (score_delta > 0), + session_id UUID REFERENCES user_sessions(session_id), + device_id VARCHAR(128), + ip INET, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (request_id) +); + +CREATE TABLE outbox_events ( + outbox_id BIGSERIAL PRIMARY KEY, + aggregate_type VARCHAR(64) NOT NULL, + aggregate_id VARCHAR(128) NOT NULL, + event_type VARCHAR(128) NOT NULL, + partition_key VARCHAR(128) NOT NULL, + payload JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + published_at TIMESTAMPTZ +); + +CREATE TABLE fraud_flags ( + flag_id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id), + score_event_id BIGINT REFERENCES score_events(event_id), + signal_type VARCHAR(64) NOT NULL, + severity SMALLINT NOT NULL CHECK (severity BETWEEN 1 AND 5), + details JSONB NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'open', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + reviewed_at TIMESTAMPTZ +); + +CREATE TABLE user_score_materialized_totals ( + user_id BIGINT PRIMARY KEY REFERENCES users(id), + total_score BIGINT NOT NULL DEFAULT 0, + action_count BIGINT NOT NULL DEFAULT 0, + reached_score_at TIMESTAMPTZ, + last_event_id BIGINT NOT NULL REFERENCES score_events(event_id), + materialized_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE projection_checkpoints ( + projection_name VARCHAR(100) PRIMARY KEY, + last_event_id BIGINT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_user_sessions_user_id_active + ON user_sessions (user_id, revoked_at, expires_at); + +CREATE INDEX idx_score_action_requests_requested_at + ON score_action_requests (requested_at DESC); + +CREATE UNIQUE INDEX idx_score_action_requests_user_action_instance + ON score_action_requests (user_id, action_id, action_instance_id) + WHERE action_instance_id IS NOT NULL; + +CREATE INDEX idx_score_events_user_occurred_at_desc + ON score_events (user_id, occurred_at DESC); + +CREATE INDEX idx_score_events_action_occurred_at_desc + ON score_events (action_id, occurred_at DESC); + +CREATE INDEX idx_score_events_occurred_at_desc + ON score_events (occurred_at DESC); + +CREATE INDEX idx_outbox_events_created_at + ON outbox_events (created_at); + +CREATE INDEX idx_outbox_events_unpublished + ON outbox_events (published_at, created_at) + WHERE published_at IS NULL; + +CREATE INDEX idx_fraud_flags_user_created_at_desc + ON fraud_flags (user_id, created_at DESC); + +CREATE INDEX idx_user_score_materialized_totals_last_event_id + ON user_score_materialized_totals (last_event_id); + +COMMIT; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000000..bde33a358f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "moduleResolution": "node", + "types": ["node"], + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "rootDir": "." + }, + "include": ["src/problem4/**/*.ts", "src/problem5/**/*.ts"] +}