diff --git a/.env.example b/.env.example index 78a13d3..2f734a6 100644 --- a/.env.example +++ b/.env.example @@ -10,14 +10,15 @@ BITCOIN_RPC_PASSWORD= BITCOIN_RPC_PORT=8332 BITCOIN_RPC_TIMEOUT=10000 -# Enable in bitcoin.conf with -# zmqpubrawblock=tcp://*:3000 -# BITCOIN_ZMQ_HOST="tcp://192.168.1.100:3000" +# Enable in bitcoin.conf with: +# zmqpubrawblock=tcp://0.0.0.0:3000 +BITCOIN_ZMQ_HOST=tcp://192.168.1.100:3000 API_PORT=3334 # Plain TCP Stratum ports accept both SV1 JSON-RPC and SV2 Noise/binary traffic. STRATUM_PORTS=3333,3332,3331,3330 +STRATUM_WORKERS=2 # Optional additional SV2-only mining ports. Usually unnecessary when STRATUM_PORTS are exposed. #STRATUM_V2_PORTS= @@ -47,10 +48,23 @@ NETWORK=mainnet API_SECURE=false #postgresql -DB_HOST= -DB_PORT= -DB_USERNAME= -DB_PASSWORD= -DB_DATABASE= +DB_HOST=timescaledb +DB_PORT=5432 +DB_USERNAME=public_pool +DB_PASSWORD=public_pool +DB_DATABASE=public_pool +DB_POOL_SIZE=10 +DB_SSL=false +DB_SSL_REJECT_UNAUTHORIZED=true + +#accepted share accounting +SHARE_ACCOUNTING_BATCH_SIZE=500 +SHARE_ACCOUNTING_FLUSH_INTERVAL_MS=25 +SHARE_ACCOUNTING_MAX_QUEUE_SIZE=50000 +SHARE_ACCOUNTING_SUMMARY_CACHE_MS=2500 +SHARE_ACCOUNTING_SUMMARY_CACHE_MAX=10000 + +#redis +REDIS_URL=redis://redis:6379 PRODUCTION=false diff --git a/.gitignore b/.gitignore index ca73bba..6bb1c3c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,10 +13,13 @@ lerna-debug.log* # OS .DS_Store +._* # Tests /coverage /.nyc_output +/.baseline-*.json +/.test-artifacts # IDEs and editors /.idea @@ -41,4 +44,4 @@ settings.json ./full-setup/data/* #https -**.pem \ No newline at end of file +**.pem diff --git a/Dockerfile b/Dockerfile index 07fa889..091e620 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,8 @@ RUN DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ python3 \ build-essential \ cmake \ + curl \ + ca-certificates \ && apt clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* WORKDIR /build @@ -18,7 +20,7 @@ WORKDIR /build COPY . . # Build Public Pool using NPM -RUN npm i && npm run build +RUN npm ci && npm run build && npm prune --omit=dev ############################ # Docker final environment # @@ -35,4 +37,4 @@ WORKDIR /public-pool COPY --from=build /build . #COPY .env.example .env -CMD ["/usr/local/bin/node", "dist/main"] +CMD ["npm", "run", "start:docker"] diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..83d45be --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,19 @@ +FROM node:24.16.0-bookworm + +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + python3 \ + build-essential \ + cmake \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +CMD ["npm", "run", "test:integration"] diff --git a/README.md b/README.md index cbc0641..4dc5562 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,13 @@ See [public-pool-ui](https://github.com/benjamin-wilson/public-pool-ui) ## Deployment -Install pm2 (https://pm2.keymetrics.io/) +The production Docker image runs database migrations and then starts PM2 +automatically. For a manual host deployment, install pm2 +(https://pm2.keymetrics.io/), run migrations, then start the app. ```bash +$ npm run build +$ npm run migration:run:prod $ pm2 start dist/main.js ``` @@ -61,43 +65,101 @@ with the default limit of `10000` allow up to `280000` connections on one port. ## Docker -Build container: +The default compose stack includes Public Pool, TimescaleDB, and Redis. TimescaleDB +stores normal Postgres tables plus immutable accepted-share rows in a hypertable. +Chart and hashrate APIs read from Timescale continuous aggregates plus recent raw +shares for realtime buckets. Redis is used only for process messaging and latest +mining-template replay. + +Start the stack: ```bash -$ docker build -t public-pool . +$ docker compose up --build -d ``` -Run container: +Use an external TimescaleDB/Postgres server: ```bash -$ docker container run --name public-pool --rm -p 3333:3333 -p 3334:3334 -p 8332:8332 -v .env:/public-pool/.env public-pool +$ DB_HOST=postgres.example.com \ + DB_PORT=5432 \ + DB_USERNAME=public_pool \ + DB_PASSWORD='change-me' \ + DB_DATABASE=public_pool \ + DB_SSL=false \ + docker compose -f docker-compose.external-db.yml up --build -d ``` -### Docker Compose +The external database must be TimescaleDB-compatible and reachable from the +Public Pool container. The startup script waits for the remote `DB_HOST:DB_PORT`, +runs migrations, then starts PM2. Redis still runs locally in this compose file +unless `REDIS_URL` is pointed at an external Redis instance. + +Set `DB_SSL=true` if the external database requires TLS. For private CA or +self-signed test deployments, `DB_SSL_REJECT_UNAUTHORIZED=false` disables +certificate verification; do not use that setting for normal internet-facing +production databases. + +For a fresh external database, create the role/database first: + +```sql +CREATE USER public_pool WITH PASSWORD 'change-me'; +CREATE DATABASE public_pool OWNER public_pool; +\c public_pool +CREATE EXTENSION IF NOT EXISTS timescaledb; +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +``` + +If TimescaleDB is managed by another operator, make sure the `public_pool` role +can create tables, indexes, continuous aggregates, and Timescale policies in the +target database. + +Run only the database migrations: -Build container: ```bash -$ docker compose build +$ docker compose run --rm public-pool npm run migration:run:prod ``` -Run container: +Watch logs: + ```bash -$ docker compose up -d +$ docker compose logs --tail 100 -f public-pool ``` -The docker-compose binds to `127.0.0.1` by default. To expose the Stratum services on your server change: -```diff - ports: -- - "127.0.0.1:3333:3333/tcp" -- - "127.0.0.1:3334:3334/tcp" -+ - "3333" -+ - "3334" +Back up TimescaleDB: + +```bash +$ docker compose exec timescaledb pg_dump -U public_pool public_pool > public-pool.sql ``` +Redis does not hold durable accounting data. Losing Redis requires workers to +replay the latest template from Redis after reconnect or fall back to the saved +RPC block template table. + **note**: To successfully connect to the bitcoin RPC you will need to add ``` rpcallowip=172.16.0.0/12 +zmqpubrawblock=tcp://0.0.0.0:3000 ``` to your bitcoin.conf. + +## Testing + +Baseline unit regression capture: + +```bash +$ npm run test:baseline +``` + +Unit tests: + +```bash +$ npm test +``` + +Integration tests against real TimescaleDB and Redis: + +```bash +$ docker compose -f docker-compose.test.yml up --build --abort-on-container-exit +``` diff --git a/docker-compose.external-db.yml b/docker-compose.external-db.yml new file mode 100644 index 0000000..0b66bcd --- /dev/null +++ b/docker-compose.external-db.yml @@ -0,0 +1,73 @@ +services: + redis: + image: redis:8-alpine + container_name: public-pool-redis + restart: unless-stopped + ports: + - "127.0.0.1:${REDIS_PORT:-6379}:6379/tcp" + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 10 + + public-pool: + container_name: public-pool + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + depends_on: + redis: + condition: service_healthy + extra_hosts: + - "host.docker.internal:host-gateway" + ports: + - "0.0.0.0:3333:3333/tcp" + - "0.0.0.0:3332:3332/tcp" + - "0.0.0.0:3331:3331/tcp" + - "0.0.0.0:3330:3330/tcp" + - "0.0.0.0:4333:4333/tcp" + - "0.0.0.0:4332:4332/tcp" + - "0.0.0.0:4331:4331/tcp" + - "0.0.0.0:4330:4330/tcp" + - "127.0.0.1:${API_PORT:-3334}:3334/tcp" + volumes: + - "./.env:/public-pool/.env:ro" + - "./secrets:/public-pool/secrets:ro" + environment: + NODE_ENV: production + DB_HOST: ${DB_HOST:?set DB_HOST to your external TimescaleDB/Postgres host} + DB_PORT: ${DB_PORT:-5432} + DB_USERNAME: ${DB_USERNAME:-public_pool} + DB_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD for your external database} + DB_DATABASE: ${DB_DATABASE:-public_pool} + DB_POOL_SIZE: ${DB_POOL_SIZE:-10} + DB_SSL: ${DB_SSL:-false} + DB_SSL_REJECT_UNAUTHORIZED: ${DB_SSL_REJECT_UNAUTHORIZED:-true} + REDIS_URL: ${REDIS_URL:-redis://redis:6379} + API_PORT: ${API_PORT:-3334} + API_SECURE: ${API_SECURE:-false} + PM2_ENABLED: ${PM2_ENABLED:-true} + STRATUM_WORKERS: ${STRATUM_WORKERS:-2} + STRATUM_PORTS: ${STRATUM_PORTS:-3333,3332,3331,3330} + STRATUM_SECURE: ${STRATUM_SECURE:-true} + SECURE_STRATUM_PORTS: ${SECURE_STRATUM_PORTS:-4333,4332,4331,4330} + STRATUM_MAX_CONNECTIONS_PER_LISTENER: ${STRATUM_MAX_CONNECTIONS_PER_LISTENER:-10000} + STRATUM_TLS_HANDSHAKE_TIMEOUT_MS: ${STRATUM_TLS_HANDSHAKE_TIMEOUT_MS:-10000} + STRATUM_V2_PORTS: ${STRATUM_V2_PORTS:-} + SHARE_ACCOUNTING_BATCH_SIZE: ${SHARE_ACCOUNTING_BATCH_SIZE:-500} + SHARE_ACCOUNTING_FLUSH_INTERVAL_MS: ${SHARE_ACCOUNTING_FLUSH_INTERVAL_MS:-25} + SHARE_ACCOUNTING_MAX_QUEUE_SIZE: ${SHARE_ACCOUNTING_MAX_QUEUE_SIZE:-50000} + SHARE_ACCOUNTING_SUMMARY_CACHE_MS: ${SHARE_ACCOUNTING_SUMMARY_CACHE_MS:-2500} + SHARE_ACCOUNTING_SUMMARY_CACHE_MAX: ${SHARE_ACCOUNTING_SUMMARY_CACHE_MAX:-10000} + healthcheck: + test: ["CMD-SHELL", "node -e \"const http=require('http'); const https=require('https'); const secure=process.env.API_SECURE==='true'; const client=secure?https:http; const req=client.get({hostname:'127.0.0.1',port:process.env.API_PORT||3334,path:'/api/network',rejectUnauthorized:false},res=>process.exit(res.statusCode<500?0:1)); req.on('error',()=>process.exit(1)); req.setTimeout(5000,()=>{req.destroy(); process.exit(1);});\""] + interval: 30s + timeout: 5s + retries: 10 + +volumes: + redis-data: diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..6add49d --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,56 @@ +name: public-pool-test + +services: + timescaledb: + image: timescale/timescaledb:latest-pg17 + environment: + POSTGRES_DB: public_pool_test + POSTGRES_USER: public_pool + POSTGRES_PASSWORD: public_pool + command: + - postgres + - -c + - max_wal_size=16GB + - -c + - min_wal_size=2GB + - -c + - checkpoint_timeout=15min + - -c + - checkpoint_completion_target=0.9 + - -c + - wal_compression=on + ports: + - "127.0.0.1:15432:5432/tcp" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U public_pool -d public_pool_test"] + interval: 5s + timeout: 5s + retries: 20 + + redis: + image: redis:8-alpine + ports: + - "127.0.0.1:16379:6379/tcp" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 20 + + integration-tests: + build: + context: . + dockerfile: Dockerfile.test + depends_on: + timescaledb: + condition: service_healthy + redis: + condition: service_healthy + environment: + NODE_ENV: integration + DB_HOST: timescaledb + DB_PORT: 5432 + DB_USERNAME: public_pool + DB_PASSWORD: public_pool + DB_DATABASE: public_pool_test + REDIS_URL: redis://redis:6379 diff --git a/docker-compose.yml b/docker-compose.yml index 8fb0927..28305fd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,20 +1,98 @@ -version: '3.8' - services: + timescaledb: + image: timescale/timescaledb:latest-pg17 + container_name: public-pool-timescaledb + restart: unless-stopped + environment: + POSTGRES_DB: ${DB_DATABASE:-public_pool} + POSTGRES_USER: ${DB_USERNAME:-public_pool} + POSTGRES_PASSWORD: ${DB_PASSWORD:-public_pool} + command: + - postgres + - -c + - max_wal_size=16GB + - -c + - min_wal_size=2GB + - -c + - checkpoint_timeout=15min + - -c + - checkpoint_completion_target=0.9 + - -c + - wal_compression=on + ports: + - "127.0.0.1:${DB_PORT:-5432}:5432/tcp" + volumes: + - timescaledb-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-public_pool} -d ${DB_DATABASE:-public_pool}"] + interval: 10s + timeout: 5s + retries: 10 + + redis: + image: redis:8-alpine + container_name: public-pool-redis + restart: unless-stopped + ports: + - "127.0.0.1:${REDIS_PORT:-6379}:6379/tcp" + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 10 + public-pool: container_name: public-pool build: context: . dockerfile: Dockerfile restart: unless-stopped + depends_on: + timescaledb: + condition: service_healthy + redis: + condition: service_healthy extra_hosts: - "host.docker.internal:host-gateway" ports: - - "127.0.0.1:${STRATUM_PORT}:${STRATUM_PORT}/tcp" - - "127.0.0.1:${API_PORT}:${API_PORT}/tcp" + - "0.0.0.0:3333:3333/tcp" + - "0.0.0.0:3332:3332/tcp" + - "0.0.0.0:3331:3331/tcp" + - "0.0.0.0:3330:3330/tcp" + - "0.0.0.0:4333:4333/tcp" + - "0.0.0.0:4332:4332/tcp" + - "0.0.0.0:4331:4331/tcp" + - "0.0.0.0:4330:4330/tcp" + - "127.0.0.1:${API_PORT:-3334}:3334/tcp" volumes: - - "./${NETWORK}-DB:/public-pool/DB" - "./.env:/public-pool/.env:ro" + - "./secrets:/public-pool/secrets:ro" environment: - - NODE_ENV=production + NODE_ENV: production + DB_HOST: timescaledb + DB_PORT: 5432 + DB_USERNAME: ${DB_USERNAME:-public_pool} + DB_PASSWORD: ${DB_PASSWORD:-public_pool} + DB_DATABASE: ${DB_DATABASE:-public_pool} + REDIS_URL: redis://redis:6379 + API_PORT: ${API_PORT:-3334} + API_SECURE: ${API_SECURE:-false} + PM2_ENABLED: ${PM2_ENABLED:-true} + STRATUM_WORKERS: ${STRATUM_WORKERS:-2} + STRATUM_PORTS: ${STRATUM_PORTS:-3333,3332,3331,3330} + STRATUM_SECURE: ${STRATUM_SECURE:-true} + SECURE_STRATUM_PORTS: ${SECURE_STRATUM_PORTS:-4333,4332,4331,4330} + STRATUM_MAX_CONNECTIONS_PER_LISTENER: ${STRATUM_MAX_CONNECTIONS_PER_LISTENER:-10000} + STRATUM_TLS_HANDSHAKE_TIMEOUT_MS: ${STRATUM_TLS_HANDSHAKE_TIMEOUT_MS:-10000} + STRATUM_V2_PORTS: ${STRATUM_V2_PORTS:-} + healthcheck: + test: ["CMD-SHELL", "node -e \"const http=require('http'); const https=require('https'); const secure=process.env.API_SECURE==='true'; const client=secure?https:http; const req=client.get({hostname:'127.0.0.1',port:process.env.API_PORT||3334,path:'/api/network',rejectUnauthorized:false},res=>process.exit(res.statusCode<500?0:1)); req.on('error',()=>process.exit(1)); req.setTimeout(5000,()=>{req.destroy(); process.exit(1);});\""] + interval: 30s + timeout: 5s + retries: 10 +volumes: + timescaledb-data: + redis-data: diff --git a/ecosystem.config.js b/ecosystem.config.js index f14b923..502b20a 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -7,6 +7,7 @@ module.exports = { instances: 1, env: { MASTER: 'true', + NODE_CLUSTER_SCHED_POLICY: 'none', }, time: true }, @@ -14,12 +15,13 @@ module.exports = { { name: 'workers', script: './dist/main.js', - instances: 2, + instances: parseInt(process.env.STRATUM_WORKERS || '2', 10), exec_mode: "cluster", env: { MASTER: 'false', + NODE_CLUSTER_SCHED_POLICY: 'none', }, time: true }, ], - }; \ No newline at end of file + }; diff --git a/full-setup/README.md b/full-setup/README.md index ec5998c..879396e 100644 --- a/full-setup/README.md +++ b/full-setup/README.md @@ -1,6 +1,7 @@ # Full Setup for public pool -This setup provides a docker-compose setup consisting of Bitcoin Core Node and Public-Pool running in Mainnet or Testnet. +This setup provides Docker Compose stacks for Bitcoin Core, Public Pool, +TimescaleDB, and Redis on mainnet, testnet, and regtest. It exposes following ports: @@ -8,6 +9,7 @@ It exposes following ports: - `8333/18333` Bitcoin peering on `0.0.0.0` - `3333/13333` Public-Pool Stratum port on `0.0.0.0` - `3334/13334` Public-Pool API port on `localhost` +- TimescaleDB and Redis only on the internal compose network The docker-compose setups for Mainnet and Testnet can be run in parallel without any problems. @@ -42,6 +44,9 @@ There are 4 config files for Mainnet and Testnet - `bitcoin-testnet.conf` **note: pruning (`prune=550`) is enabled by default in the config** +**note: Bitcoin ZMQ raw block publishing is enabled on the internal compose +network so the Public Pool master can refresh templates immediately after new +blocks.** # Running the setup To start the setup in foreground mode: @@ -65,6 +70,25 @@ To stop the setup use: docker compose -f docker-compose-mainnet.yml down ``` +Database and Redis data are stored under `full-setup/data//`. To remove +all runtime data, stop the stack and delete the relevant `bitcoin`, +`timescaledb`, and `redis` directories. + +# Migrations and backups + +The Public Pool container waits for TimescaleDB and Redis, runs migrations, and +then starts PM2. To run migrations manually: + +```bash +docker compose -f docker-compose-mainnet.yml run --rm public-pool npm run migration:run:prod +``` + +To back up mainnet accounting data: + +```bash +docker compose -f docker-compose-mainnet.yml exec timescaledb pg_dump -U public_pool public_pool_mainnet > public-pool-mainnet.sql +``` + # Regtest After running the `regtest` setup a couple of blocks need to be generated: diff --git a/full-setup/bitcoin-mainnet.conf b/full-setup/bitcoin-mainnet.conf index 716ad14..39da218 100644 --- a/full-setup/bitcoin-mainnet.conf +++ b/full-setup/bitcoin-mainnet.conf @@ -18,6 +18,9 @@ rpcuser=bitcoin # enable server server=1 +# notify pool master about new blocks +zmqpubrawblock=tcp://0.0.0.0:3000 + [main] [test] rpcbind=0.0.0.0 diff --git a/full-setup/bitcoin-regtest.conf b/full-setup/bitcoin-regtest.conf index ac1d3c8..7c6522d 100644 --- a/full-setup/bitcoin-regtest.conf +++ b/full-setup/bitcoin-regtest.conf @@ -20,3 +20,6 @@ rpcuser=bitcoin # set pruning prune=550 + +# notify pool master about new blocks +zmqpubrawblock=tcp://0.0.0.0:3000 diff --git a/full-setup/bitcoin-testnet.conf b/full-setup/bitcoin-testnet.conf index 2734ed1..39da218 100644 --- a/full-setup/bitcoin-testnet.conf +++ b/full-setup/bitcoin-testnet.conf @@ -18,6 +18,9 @@ rpcuser=bitcoin # enable server server=1 +# notify pool master about new blocks +zmqpubrawblock=tcp://0.0.0.0:3000 + [main] [test] rpcbind=0.0.0.0 @@ -25,4 +28,4 @@ rpcport=18332 # no additional testnet settings [signet] -[regtest] \ No newline at end of file +[regtest] diff --git a/full-setup/docker-compose-mainnet.yml b/full-setup/docker-compose-mainnet.yml index a97ed43..7ba35aa 100644 --- a/full-setup/docker-compose-mainnet.yml +++ b/full-setup/docker-compose-mainnet.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: bitcoin: container_name: bitcoin @@ -20,6 +18,50 @@ services: - "./data/mainnet/bitcoin:/app/data" - "./bitcoin-mainnet.conf:/app/data/bitcoin.conf:ro" + timescaledb: + image: timescale/timescaledb:latest-pg17 + container_name: public-pool-mainnet-timescaledb + restart: unless-stopped + networks: + - bitcoin + environment: + POSTGRES_DB: public_pool_mainnet + POSTGRES_USER: public_pool + POSTGRES_PASSWORD: public_pool + command: + - postgres + - -c + - max_wal_size=16GB + - -c + - min_wal_size=2GB + - -c + - checkpoint_timeout=15min + - -c + - checkpoint_completion_target=0.9 + - -c + - wal_compression=on + volumes: + - "./data/mainnet/timescaledb:/var/lib/postgresql/data" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U public_pool -d public_pool_mainnet"] + interval: 10s + timeout: 5s + retries: 10 + + redis: + image: redis:8-alpine + container_name: public-pool-mainnet-redis + restart: unless-stopped + networks: + - bitcoin + volumes: + - "./data/mainnet/redis:/data" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 10 + public-pool: container_name: public-pool build: @@ -27,16 +69,36 @@ services: dockerfile: Dockerfile restart: unless-stopped stop_grace_period: 30s + depends_on: + timescaledb: + condition: service_healthy + redis: + condition: service_healthy networks: - bitcoin ports: - "0.0.0.0:3333:3333/tcp" + - "0.0.0.0:3332:3332/tcp" + - "0.0.0.0:3331:3331/tcp" + - "0.0.0.0:3330:3330/tcp" + - "0.0.0.0:4333:4333/tcp" + - "0.0.0.0:4332:4332/tcp" + - "0.0.0.0:4331:4331/tcp" + - "0.0.0.0:4330:4330/tcp" - "127.0.0.1:3334:3334/tcp" volumes: - - "./data/mainnet/public-pool:/public-pool/DB" - "./public-pool-mainnet.env:/public-pool/.env:ro" + - "../secrets:/public-pool/secrets:ro" environment: - - NODE_ENV=production + NODE_ENV: production + DB_HOST: timescaledb + DB_PORT: 5432 + DB_USERNAME: public_pool + DB_PASSWORD: public_pool + DB_DATABASE: public_pool_mainnet + REDIS_URL: redis://redis:6379 + PM2_ENABLED: "true" + STRATUM_WORKERS: ${STRATUM_WORKERS:-2} networks: bitcoin: diff --git a/full-setup/docker-compose-regtest.yml b/full-setup/docker-compose-regtest.yml index 1b7ec10..06c4e40 100644 --- a/full-setup/docker-compose-regtest.yml +++ b/full-setup/docker-compose-regtest.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: bitcoin-regtest: container_name: bitcoin-regtest @@ -22,6 +20,50 @@ services: - "./data/regtest/bitcoin:/app/data" - "./bitcoin-regtest.conf:/app/data/bitcoin.conf:ro" + timescaledb-regtest: + image: timescale/timescaledb:latest-pg17 + container_name: public-pool-regtest-timescaledb + restart: unless-stopped + networks: + - bitcoin-regtest + environment: + POSTGRES_DB: public_pool_regtest + POSTGRES_USER: public_pool + POSTGRES_PASSWORD: public_pool + command: + - postgres + - -c + - max_wal_size=16GB + - -c + - min_wal_size=2GB + - -c + - checkpoint_timeout=15min + - -c + - checkpoint_completion_target=0.9 + - -c + - wal_compression=on + volumes: + - "./data/regtest/timescaledb:/var/lib/postgresql/data" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U public_pool -d public_pool_regtest"] + interval: 10s + timeout: 5s + retries: 10 + + redis-regtest: + image: redis:8-alpine + container_name: public-pool-regtest-redis + restart: unless-stopped + networks: + - bitcoin-regtest + volumes: + - "./data/regtest/redis:/data" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 10 + public-pool-regtest: container_name: public-pool-regtest build: @@ -29,16 +71,32 @@ services: dockerfile: Dockerfile restart: unless-stopped stop_grace_period: 30s + depends_on: + timescaledb-regtest: + condition: service_healthy + redis-regtest: + condition: service_healthy networks: - bitcoin-regtest ports: - "0.0.0.0:23333:23333/tcp" + - "0.0.0.0:23332:23332/tcp" + - "0.0.0.0:23331:23331/tcp" + - "0.0.0.0:23330:23330/tcp" - "127.0.0.1:23334:23334/tcp" volumes: - - "./data/regtest/public-pool:/public-pool/DB" - "./public-pool-regtest.env:/public-pool/.env:ro" + - "../secrets:/public-pool/secrets:ro" environment: - - NODE_ENV=production + NODE_ENV: production + DB_HOST: timescaledb-regtest + DB_PORT: 5432 + DB_USERNAME: public_pool + DB_PASSWORD: public_pool + DB_DATABASE: public_pool_regtest + REDIS_URL: redis://redis-regtest:6379 + PM2_ENABLED: "true" + STRATUM_WORKERS: ${STRATUM_WORKERS:-2} networks: bitcoin-regtest: diff --git a/full-setup/docker-compose-testnet.yml b/full-setup/docker-compose-testnet.yml index fc1abfe..82819fc 100644 --- a/full-setup/docker-compose-testnet.yml +++ b/full-setup/docker-compose-testnet.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: bitcoin-testnet: container_name: bitcoin-testnet @@ -21,6 +19,50 @@ services: - "./data/testnet/bitcoin:/app/data" - "./bitcoin-testnet.conf:/app/data/bitcoin.conf:ro" + timescaledb-testnet: + image: timescale/timescaledb:latest-pg17 + container_name: public-pool-testnet-timescaledb + restart: unless-stopped + networks: + - bitcoin-testnet + environment: + POSTGRES_DB: public_pool_testnet + POSTGRES_USER: public_pool + POSTGRES_PASSWORD: public_pool + command: + - postgres + - -c + - max_wal_size=16GB + - -c + - min_wal_size=2GB + - -c + - checkpoint_timeout=15min + - -c + - checkpoint_completion_target=0.9 + - -c + - wal_compression=on + volumes: + - "./data/testnet/timescaledb:/var/lib/postgresql/data" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U public_pool -d public_pool_testnet"] + interval: 10s + timeout: 5s + retries: 10 + + redis-testnet: + image: redis:8-alpine + container_name: public-pool-testnet-redis + restart: unless-stopped + networks: + - bitcoin-testnet + volumes: + - "./data/testnet/redis:/data" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 10 + public-pool-testnet: container_name: public-pool-testnet build: @@ -28,16 +70,32 @@ services: dockerfile: Dockerfile restart: unless-stopped stop_grace_period: 30s + depends_on: + timescaledb-testnet: + condition: service_healthy + redis-testnet: + condition: service_healthy networks: - bitcoin-testnet ports: - "0.0.0.0:13333:13333/tcp" + - "0.0.0.0:13332:13332/tcp" + - "0.0.0.0:13331:13331/tcp" + - "0.0.0.0:13330:13330/tcp" - "127.0.0.1:13334:13334/tcp" volumes: - - "./data/testnet/public-pool:/public-pool/DB" - "./public-pool-testnet.env:/public-pool/.env:ro" + - "../secrets:/public-pool/secrets:ro" environment: - - NODE_ENV=production + NODE_ENV: production + DB_HOST: timescaledb-testnet + DB_PORT: 5432 + DB_USERNAME: public_pool + DB_PASSWORD: public_pool + DB_DATABASE: public_pool_testnet + REDIS_URL: redis://redis-testnet:6379 + PM2_ENABLED: "true" + STRATUM_WORKERS: ${STRATUM_WORKERS:-2} networks: bitcoin-testnet: diff --git a/full-setup/public-pool-mainnet.env b/full-setup/public-pool-mainnet.env index 587dc79..a87b973 100644 --- a/full-setup/public-pool-mainnet.env +++ b/full-setup/public-pool-mainnet.env @@ -4,12 +4,14 @@ BITCOIN_RPC_PASSWORD=bitcoin BITCOIN_RPC_PORT=8332 BITCOIN_RPC_TIMEOUT=10000 -# Enable in bitcoin.conf with -# zmqpubrawblock=tcp://*:3000 -# BITCOIN_ZMQ_HOST="tcp://192.168.1.100:3000" +BITCOIN_ZMQ_HOST=tcp://bitcoin:3000 API_PORT=3334 -STRATUM_PORT=3333 +STRATUM_PORTS=3333,3332,3331,3330 +STRATUM_SECURE=true +SECURE_STRATUM_PORTS=4333,4332,4331,4330 +STRATUM_MAX_CONNECTIONS_PER_LISTENER=10000 +STRATUM_TLS_HANDSHAKE_TIMEOUT_MS=10000 #optional telegram bot #TELEGRAM_BOT_TOKEN= @@ -25,3 +27,11 @@ DEV_FEE_ADDRESS= NETWORK=mainnet API_SECURE=false + +DB_HOST=timescaledb +DB_PORT=5432 +DB_USERNAME=public_pool +DB_PASSWORD=public_pool +DB_DATABASE=public_pool_mainnet +REDIS_URL=redis://redis:6379 +PRODUCTION=true diff --git a/full-setup/public-pool-regtest.env b/full-setup/public-pool-regtest.env index 7f21887..b5cd7b0 100644 --- a/full-setup/public-pool-regtest.env +++ b/full-setup/public-pool-regtest.env @@ -4,12 +4,14 @@ BITCOIN_RPC_PASSWORD=bitcoin BITCOIN_RPC_PORT=28332 BITCOIN_RPC_TIMEOUT=10000 -# Enable in bitcoin.conf with -# zmqpubrawblock=tcp://*:3000 -# BITCOIN_ZMQ_HOST="tcp://192.168.1.100:3000" +BITCOIN_ZMQ_HOST=tcp://bitcoin-regtest:3000 API_PORT=23334 -STRATUM_PORT=23333 +STRATUM_PORTS=23333,23332,23331,23330 +STRATUM_SECURE=false +SECURE_STRATUM_PORTS= +STRATUM_MAX_CONNECTIONS_PER_LISTENER=10000 +STRATUM_TLS_HANDSHAKE_TIMEOUT_MS=10000 #optional telegram bot #TELEGRAM_BOT_TOKEN= @@ -25,3 +27,11 @@ DEV_FEE_ADDRESS= NETWORK=regtest API_SECURE=false + +DB_HOST=timescaledb-regtest +DB_PORT=5432 +DB_USERNAME=public_pool +DB_PASSWORD=public_pool +DB_DATABASE=public_pool_regtest +REDIS_URL=redis://redis-regtest:6379 +PRODUCTION=true diff --git a/full-setup/public-pool-testnet.env b/full-setup/public-pool-testnet.env index 506d5fb..55d5cb6 100644 --- a/full-setup/public-pool-testnet.env +++ b/full-setup/public-pool-testnet.env @@ -4,12 +4,14 @@ BITCOIN_RPC_PASSWORD=bitcoin BITCOIN_RPC_PORT=18332 BITCOIN_RPC_TIMEOUT=10000 -# Enable in bitcoin.conf with -# zmqpubrawblock=tcp://*:3000 -# BITCOIN_ZMQ_HOST="tcp://192.168.1.100:3000" +BITCOIN_ZMQ_HOST=tcp://bitcoin-testnet:3000 API_PORT=13334 -STRATUM_PORT=13333 +STRATUM_PORTS=13333,13332,13331,13330 +STRATUM_SECURE=false +SECURE_STRATUM_PORTS= +STRATUM_MAX_CONNECTIONS_PER_LISTENER=10000 +STRATUM_TLS_HANDSHAKE_TIMEOUT_MS=10000 #optional telegram bot #TELEGRAM_BOT_TOKEN= @@ -24,4 +26,12 @@ DEV_FEE_ADDRESS= # mainnet | testnet NETWORK=testnet -API_SECURE=false \ No newline at end of file +API_SECURE=false + +DB_HOST=timescaledb-testnet +DB_PORT=5432 +DB_USERNAME=public_pool +DB_PASSWORD=public_pool +DB_DATABASE=public_pool_testnet +REDIS_URL=redis://redis-testnet:6379 +PRODUCTION=true diff --git a/package-lock.json b/package-lock.json index 3745f1b..4fddc84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,6 @@ "@nestjs/typeorm": "^11.0.1", "@scure/btc-signer": "^2.2.0", "axios": "^1.16.0", - "better-sqlite3": "^12.9.0", "bitcoin-address-validation": "^2.2.3", "bitcoinjs-lib": "^6.1.7", "bs58": "^5.0.0", @@ -28,9 +27,11 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.4", "discord.js": "^14.26.4", + "dotenv": "^16.6.1", "merkle-lib": "^2.0.10", "pg": "^8.20.0", - "pg-pubsub": "^0.8.1", + "pm2": "^7.0.1", + "redis": "^5.12.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "tiny-secp256k1": "^2.2.4", @@ -2364,6 +2365,18 @@ "rxjs": "^7.1.0" } }, + "node_modules/@nestjs/config/node_modules/dotenv": { + "version": "17.4.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", + "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/@nestjs/core": { "version": "11.1.19", "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.19.tgz", @@ -2674,6 +2687,162 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@pm2/blessed": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@pm2/blessed/-/blessed-0.1.81.tgz", + "integrity": "sha512-ZcNHqQjMuNRcQ7Z1zJbFIQZO/BDKV3KbiTckWdfbUaYhj7uNmUwb+FbdDWSCkvxNr9dBJQwvV17o6QBkAvgO0g==", + "license": "MIT", + "bin": { + "blessed": "bin/tput.js" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@pm2/js-api": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@pm2/js-api/-/js-api-0.8.0.tgz", + "integrity": "sha512-nmWzrA/BQZik3VBz+npRcNIu01kdBhWL0mxKmP1ciF/gTcujPTQqt027N9fc1pK9ERM8RipFhymw7RcmCyOEYA==", + "license": "Apache-2", + "dependencies": { + "async": "^2.6.3", + "debug": "~4.3.1", + "eventemitter2": "^6.3.1", + "extrareqp2": "^1.0.0", + "ws": "^7.0.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/@pm2/js-api/node_modules/async": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.14" + } + }, + "node_modules/@pm2/js-api/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@pm2/js-api/node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@pm2/pm2-version-check": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@pm2/pm2-version-check/-/pm2-version-check-1.0.4.tgz", + "integrity": "sha512-SXsM27SGH3yTWKc2fKR4SYNxsmnvuBQ9dd6QHtEWmiZ/VqaOYPAIlS8+vMcn27YLtAEBGvNRSh3TPNvtjZgfqA==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.1" + } + }, + "node_modules/@redis/bloom": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.12.1.tgz", + "integrity": "sha512-PUUfv+ms7jgPSBVoo/DN4AkPHj4D5TZSd6SbJX7egzBplkYUcKmHRE8RKia7UtZ8bSQbLguLvxVO+asKtQfZWA==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/client": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz", + "integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0", + "@opentelemetry/api": ">=1 <2" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@redis/json": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.12.1.tgz", + "integrity": "sha512-eOze75esLve4vfqDel7aMX08CNaiLLQS2fV8mpRN9NxPe1rVR4vQyYiW/OgtGUysF6QOr9ANhfxABKNOJfXdKg==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/search": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.12.1.tgz", + "integrity": "sha512-ItlxbxC9cKI6IU1TLWoczwJCRb6TdmkEpWv05UrPawqaAnWGRu3rcIqsc5vN483T2fSociuyV1UkWIL5I4//2w==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/time-series": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.12.1.tgz", + "integrity": "sha512-c6JL6E3EcZJuNqKFz+KM+l9l5mpcQiKvTwgA3blt5glWJ8hjDk0yeHN3beE/MpqYIQ8UEX44ItQzgkE/gCBELQ==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, "node_modules/@sapphire/async-queue": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", @@ -2809,6 +2978,12 @@ "node": ">= 6" } }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "license": "MIT" + }, "node_modules/@tsconfig/node10": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", @@ -3708,6 +3883,21 @@ "ajv": "^6.9.1" } }, + "node_modules/amp": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/amp/-/amp-0.3.1.tgz", + "integrity": "sha512-OwIuC4yZaRogHKiuU5WlMR5Xk/jAcpPtawWL05Gj8Lvm2F6mwoJt4O/bHI+DHwG79vWd+8OFYM4/BzYqyRd3qw==", + "license": "MIT" + }, + "node_modules/amp-message": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/amp-message/-/amp-message-0.1.2.tgz", + "integrity": "sha512-JqutcFwoU1+jhv7ArgW38bqrE+LQdcRv4NxNw0mp0JHQyB6tXesWRjtYKlDgHRY2o3JE5UTaBGUK8kSWUdxWUg==", + "license": "MIT", + "dependencies": { + "amp": "0.3.1" + } + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -3769,7 +3959,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -3820,8 +4009,7 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/array-timsort": { "version": "1.0.3", @@ -3847,6 +4035,24 @@ "dev": true, "license": "MIT" }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -4052,29 +4258,54 @@ "node": ">=6.0.0" } }, + "node_modules/basic-ftp": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bech32": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz", "integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==" }, "node_modules/better-sqlite3": { - "version": "12.9.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz", - "integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==", + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", + "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", "hasInstallScript": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" }, "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x" + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "optional": true, + "peer": true, "dependencies": { "file-uri-to-path": "1.0.0" } @@ -4129,6 +4360,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "devOptional": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -4139,6 +4371,7 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "devOptional": true, "funding": [ { "type": "github", @@ -4158,6 +4391,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/bodec": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bodec/-/bodec-0.1.0.tgz", + "integrity": "sha512-Ylo+MAo5BDUq1KA3f3R/MFhh+g8cnHmo8bz3YPGhI1znrMaf77ol1sfvYJzsw3nTE+Y2GryfDxBaR+AqpAkEHQ==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", @@ -4172,7 +4411,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -4612,6 +4850,30 @@ "@colors/colors": "1.5.0" } }, + "node_modules/cli-tableau": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cli-tableau/-/cli-tableau-2.0.1.tgz", + "integrity": "sha512-he+WTicka9cl0Fg/y+YyxcN6/bfQ/1O3QmgxRXDhABKqLzvoOSM4fMzp39uMyLBulAFuywD2N7UaoQE7WaADxQ==", + "dependencies": { + "chalk": "3.0.0" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/cli-tableau/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -4645,6 +4907,15 @@ "node": ">=0.8" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -4864,6 +5135,12 @@ "url": "https://ko-fi.com/intcreator" } }, + "node_modules/croner": { + "version": "4.1.97", + "resolved": "https://registry.npmjs.org/croner/-/croner-4.1.97.tgz", + "integrity": "sha512-/f6gpQuxDaqXu+1kwQYSckUglPaOrHdbIlBAu0YuW8/Cdb45XwXYNUBXg3r/9Mo6n540Kn/smKcZWko5x99KrQ==", + "license": "MIT" + }, "node_modules/cross-env": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", @@ -4895,6 +5172,21 @@ "node": ">= 8" } }, + "node_modules/culvert": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/culvert/-/culvert-0.1.2.tgz", + "integrity": "sha512-yi1x3EAWKjQTreYWeSd98431AV+IEE0qoDyOoaHJ7KJ21gv6HtBXHVLX74opVSGqcR8/AbjJBHAHpcOy2bj5Gg==", + "license": "MIT" + }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/dayjs": { "version": "1.11.20", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", @@ -4923,6 +5215,8 @@ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "mimic-response": "^3.1.0" }, @@ -4952,6 +5246,8 @@ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=4.0.0" } @@ -5001,6 +5297,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -5031,6 +5341,8 @@ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", + "optional": true, + "peer": true, "engines": { "node": ">=8" } @@ -5138,9 +5450,9 @@ } }, "node_modules/dotenv": { - "version": "17.4.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", - "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -5164,18 +5476,6 @@ "url": "https://dotenvx.com" } }, - "node_modules/dotenv-expand/node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/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://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -5250,6 +5550,8 @@ "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "optional": true, + "peer": true, "dependencies": { "once": "^1.4.0" } @@ -5356,6 +5658,46 @@ "node": ">=6" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/escodegen/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/escodegen/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/eslint": { "version": "8.57.1", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", @@ -5559,7 +5901,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" @@ -5623,11 +5964,16 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, "engines": { "node": ">=0.10.0" } }, + "node_modules/eventemitter2": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", + "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==", + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -5675,6 +6021,8 @@ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", "license": "(MIT OR WTFPL)", + "optional": true, + "peer": true, "engines": { "node": ">=6" } @@ -5696,6 +6044,15 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/extrareqp2": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/extrareqp2/-/extrareqp2-1.0.0.tgz", + "integrity": "sha512-Gum0g1QYb6wpPJCVypWP3bbIuaibcFiJcpuPM10YSXp/tzqi84x9PJageob+eN4xVRIOto4wjSGNLyMD54D2xA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.14.0" + } + }, "node_modules/fast-decode-uri-component": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", @@ -5731,6 +6088,12 @@ "node": ">=8.6.0" } }, + "node_modules/fast-json-patch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz", + "integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==", + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -5920,13 +6283,14 @@ "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "optional": true, + "peer": true }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -6123,7 +6487,9 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/fs-extra": { "version": "10.1.0", @@ -6184,7 +6550,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -6301,11 +6666,39 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/git-node-fs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/git-node-fs/-/git-node-fs-1.0.0.tgz", + "integrity": "sha512-bLQypt14llVXBg0S0u8q8HmU7g9p3ysH+NvVlae5vILuUvs759665HvmR5+wb04KjHyjFcDRxdYb4kyNnluMUQ==", + "license": "MIT" + }, + "node_modules/git-sha1": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/git-sha1/-/git-sha1-0.1.2.tgz", + "integrity": "sha512-2e/nZezdVlyCopOCYHeW0onkbZg7xP1Ad6pndPy1rCygeRykefUS6r7oA5cJRGEFvseiaz5a/qUHFVX1dd6Isg==", + "license": "MIT" + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/glob": { "version": "13.0.6", @@ -6329,7 +6722,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -6522,7 +6914,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "engines": { "node": ">=8" } @@ -6811,8 +7202,6 @@ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">= 12" } @@ -6832,6 +7221,18 @@ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -6873,7 +7274,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -6900,7 +7300,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -6930,7 +7329,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -7834,6 +8232,18 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/js-git": { + "version": "0.7.8", + "resolved": "https://registry.npmjs.org/js-git/-/js-git-0.7.8.tgz", + "integrity": "sha512-+E5ZH/HeRnoc/LW0AmAyhU+mNcWBzAKE+30+IDMLSLbbK+Tdt02AdkOKq9u15rlJsDEGFqtgckc8ZM59LhhiUA==", + "license": "MIT", + "dependencies": { + "bodec": "^0.1.0", + "culvert": "^0.1.2", + "git-sha1": "^0.1.2", + "pako": "^0.2.5" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7845,7 +8255,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -7928,6 +8337,13 @@ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "dev": true }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC", + "optional": true + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -8410,6 +8826,8 @@ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=10" }, @@ -8638,7 +9056,9 @@ "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/ms": { "version": "2.1.3", @@ -8660,7 +9080,9 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/natural-compare": { "version": "1.4.0", @@ -8691,11 +9113,22 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, + "node_modules/netmask": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", + "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/node-abi": { "version": "3.92.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "semver": "^7.3.5" }, @@ -8811,7 +9244,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9016,12 +9448,99 @@ "node": ">=6" } }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-proxy-agent/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-proxy-agent/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-proxy-agent/node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/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/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -9218,14 +9737,6 @@ "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", "license": "MIT" }, - "node_modules/pg-format": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/pg-format/-/pg-format-1.0.4.tgz", - "integrity": "sha512-YyKEF78pEA6wwTAqOUaHIN/rWpfzzIuMh9KdAhc3rSLQ/7zkRFcCgYBAEGatDstLyZw4g0s9SNICmaTGnBVeyw==", - "engines": { - "node": ">=4.0" - } - }, "node_modules/pg-int8": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", @@ -9249,20 +9760,6 @@ "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", "license": "MIT" }, - "node_modules/pg-pubsub": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/pg-pubsub/-/pg-pubsub-0.8.1.tgz", - "integrity": "sha512-b/EHOwCrag4isghc4XgRipeAjfgyNg1DiL3Dwwh1Ojp91Lriltn5eg2nSWjBe4pzcFzhTM6HiB7LOG9NN1nx5g==", - "dependencies": { - "pg": "^8.7.3", - "pg-format": "^1.0.2", - "pony-cause": "^2.1.8", - "promised-retry": "^0.5.0" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - } - }, "node_modules/pg-types": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", @@ -9297,7 +9794,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -9306,6 +9802,18 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pidusage": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pidusage/-/pidusage-4.0.1.tgz", + "integrity": "sha512-yCH2dtLHfEBnzlHUJymR/Z1nN2ePG3m392Mv8TFlTP1B0xkpMQNHAnfkY0n2tAi6ceKO6YWhxYfZ96V4vVkh/g==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/pino": { "version": "10.3.1", "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", @@ -9431,12 +9939,155 @@ "node": ">=4" } }, - "node_modules/pony-cause": { - "version": "2.1.11", - "resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-2.1.11.tgz", - "integrity": "sha512-M7LhCsdNbNgiLYiP4WjsfLUuFmCfnjdF6jKe2R9NKl4WFN+HZPGHJZ9lnLP7f9ZnKe3U9nuWD0szirmj+migUg==", + "node_modules/pm2": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pm2/-/pm2-7.0.1.tgz", + "integrity": "sha512-h/DI7WomfdAWDzDRtjhnNfsG4Do6kuOY+VGKbqffHW3yRZMUoWzwyBDkXjrOWvnBlwuY3lYBKkozDdCagfRI4Q==", + "license": "AGPL-3.0", + "dependencies": { + "@pm2/blessed": "0.1.81", + "@pm2/js-api": "0.8.0", + "@pm2/pm2-version-check": "1.0.4", + "amp": "0.3.1", + "amp-message": "0.1.2", + "ansis": "4.0.0-node10", + "async": "3.2.6", + "chokidar": "3.6.0", + "cli-tableau": "2.0.1", + "commander": "2.15.1", + "croner": "4.1.97", + "dayjs": "1.11.15", + "debug": "4.4.3", + "eventemitter2": "6.4.9", + "fast-json-patch": "3.1.1", + "js-yaml": "4.1.1", + "pidusage": "4.0.1", + "pm2-deploy": "1.0.2", + "proxy-agent": "6.5.0", + "semver": "7.7.2", + "vizion": "2.2.1", + "ws": "8.20.0" + }, + "bin": { + "pm2": "bin/pm2", + "pm2-dev": "bin/pm2-dev", + "pm2-docker": "bin/pm2-docker", + "pm2-runtime": "bin/pm2-runtime" + }, "engines": { - "node": ">=12.0.0" + "node": ">=18.0.0" + }, + "optionalDependencies": { + "pm2-sysmonit": "^1.2.8" + } + }, + "node_modules/pm2-deploy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pm2-deploy/-/pm2-deploy-1.0.2.tgz", + "integrity": "sha512-YJx6RXKrVrWaphEYf++EdOOx9EH18vM8RSZN/P1Y+NokTKqYAca/ejXwVLyiEpNju4HPZEk3Y2uZouwMqUlcgg==", + "license": "MIT", + "dependencies": { + "run-series": "^1.1.8", + "tv4": "^1.3.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pm2-sysmonit": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/pm2-sysmonit/-/pm2-sysmonit-1.2.8.tgz", + "integrity": "sha512-ACOhlONEXdCTVwKieBIQLSi2tQZ8eKinhcr9JpZSUAL8Qy0ajIgRtsLxG/lwPOW3JEKqPyw/UaHmTWhUzpP4kA==", + "license": "Apache", + "optional": true, + "dependencies": { + "async": "^3.2.0", + "debug": "^4.3.1", + "pidusage": "^2.0.21", + "systeminformation": "^5.7", + "tx2": "~1.0.4" + } + }, + "node_modules/pm2-sysmonit/node_modules/pidusage": { + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/pidusage/-/pidusage-2.0.21.tgz", + "integrity": "sha512-cv3xAQos+pugVX+BfXpHsbyz/dLzX+lr44zNMsYiGxUw+kV5sgQCIcLd1z+0vq+KyC7dJ+/ts2PsfgWfSC3WXA==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pm2/node_modules/ansis": { + "version": "4.0.0-node10", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.0.0-node10.tgz", + "integrity": "sha512-BRrU0Bo1X9dFGw6KgGz6hWrqQuOlVEDOzkb0QSLZY9sXHqA7pNj7yHPVJRz7y/rj4EOJ3d/D5uxH+ee9leYgsg==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/pm2/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "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/pm2/node_modules/commander": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz", + "integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==", + "license": "MIT" + }, + "node_modules/pm2/node_modules/dayjs": { + "version": "1.11.15", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.15.tgz", + "integrity": "sha512-MC+DfnSWiM9APs7fpiurHGCoeIx0Gdl6QZBy+5lu8MbYKN5FZEXqOgrundfibdfhGZ15o9hzmZ2xJjZnbvgKXQ==", + "license": "MIT" + }, + "node_modules/pm2/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/pm2/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/possible-typed-array-names": { @@ -9489,6 +10140,8 @@ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", @@ -9615,25 +10268,6 @@ "node": ">=10" } }, - "node_modules/promised-retry": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/promised-retry/-/promised-retry-0.5.0.tgz", - "integrity": "sha512-jbYvN6UGE+/3E1g0JmgDPchUc+4VI4cBaPjdr2Lso22xfFqut2warEf6IhWuhPJKbJYVOQAyCt2Jx+01ORCItg==", - "dependencies": { - "pony-cause": "^1.1.1" - }, - "engines": { - "node": "^14.17.0 || >=16.0.0" - } - }, - "node_modules/promised-retry/node_modules/pony-cause": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-1.1.1.tgz", - "integrity": "sha512-PxkIc/2ZpLiEzQXu5YRDOUgBlfGYBY8156HY5ZcRAwwonMk5W/MrJP2LLkG/hF7GEQzaHo2aS7ho6ZLCOvf+6g==", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -9648,6 +10282,89 @@ "node": ">= 6" } }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-agent/node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/proxy-agent/node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -9661,6 +10378,8 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "optional": true, + "peer": true, "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -9740,6 +10459,8 @@ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "peer": true, "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", @@ -9755,6 +10476,8 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -9769,6 +10492,7 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "devOptional": true, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -9813,6 +10537,22 @@ "node": ">= 0.10" } }, + "node_modules/redis": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz", + "integrity": "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==", + "license": "MIT", + "dependencies": { + "@redis/bloom": "5.12.1", + "@redis/client": "5.12.1", + "@redis/json": "5.12.1", + "@redis/search": "5.12.1", + "@redis/time-series": "5.12.1" + }, + "engines": { + "node": ">= 18.19.0" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -10005,6 +10745,26 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/run-series": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/run-series/-/run-series-1.1.9.tgz", + "integrity": "sha512-Arc4hUN896vjkqCYrUXquBFtRZdv1PfLbTYP71efP6butxyQ0kWpiNJyAgsxscmQg1cqvHY32/UCBzXedTpU2g==", + "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/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -10340,7 +11100,9 @@ "url": "https://feross.org/support" } ], - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/simple-get": { "version": "4.0.1", @@ -10361,6 +11123,8 @@ } ], "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", @@ -10388,8 +11152,6 @@ "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">= 6.0.0", "npm": ">= 3.0.0" @@ -10400,8 +11162,6 @@ "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.8.tgz", "integrity": "sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==", "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" @@ -10574,6 +11334,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "devOptional": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -10731,7 +11492,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -10776,6 +11536,33 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/systeminformation": { + "version": "5.31.7", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.7.tgz", + "integrity": "sha512-/8NC53e5nP9nmhn42/ncdOkyJnOoue/Vy+tJOyUGd1Yv66G069wK4rrziwhrqDETgk78CudTQupw5z19S5uoZw==", + "license": "MIT", + "optional": true, + "os": [ + "darwin", + "linux", + "win32", + "freebsd", + "openbsd", + "netbsd", + "sunos", + "android" + ], + "bin": { + "systeminformation": "lib/cli.js" + }, + "engines": { + "node": ">=8.0.0" + }, + "funding": { + "type": "Buy me a coffee", + "url": "https://www.buymeacoffee.com/systeminfo" + } + }, "node_modules/tapable": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", @@ -10815,6 +11602,8 @@ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", @@ -10826,13 +11615,17 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" + "license": "ISC", + "optional": true, + "peer": true }, "node_modules/tar-stream": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -11077,7 +11870,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/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" @@ -11311,6 +12103,8 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "optional": true, + "peer": true, "dependencies": { "safe-buffer": "^5.0.1" }, @@ -11318,6 +12112,34 @@ "node": "*" } }, + "node_modules/tv4": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tv4/-/tv4-1.3.0.tgz", + "integrity": "sha512-afizzfpJgvPr+eDkREK4MxJ/+r8nEEHcmitwgnPUqpaP+FpwQyadnxNoSACbgc/b1LsZYtODGoPiFxQrgJgjvw==", + "license": [ + { + "type": "Public Domain", + "url": "http://geraintluff.github.io/tv4/LICENSE.txt" + }, + { + "type": "MIT", + "url": "http://jsonary.com/LICENSE.txt" + } + ], + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/tx2": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tx2/-/tx2-1.0.5.tgz", + "integrity": "sha512-sJ24w0y03Md/bxzK4FU8J8JveYYUbSs2FViLJ2D/8bytSiyPRbuE3DyL/9UKYXTZlV3yXq0L8GLlhobTnekCVg==", + "license": "MIT", + "optional": true, + "dependencies": { + "json-stringify-safe": "^5.0.1" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -11484,18 +12306,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/typeorm/node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/typeorm/node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -11694,7 +12504,8 @@ "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "devOptional": true }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", @@ -11734,6 +12545,30 @@ "safe-buffer": "^5.1.1" } }, + "node_modules/vizion": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/vizion/-/vizion-2.2.1.tgz", + "integrity": "sha512-sfAcO2yeSU0CSPFI/DmZp3FsFE9T+8913nv1xWBOyzODv13fwkn6Vl7HqxGpkr9F608M+8SuFId3s+BlZqfXww==", + "license": "Apache-2.0", + "dependencies": { + "async": "^2.6.3", + "git-node-fs": "^1.0.0", + "ini": "^1.3.5", + "js-git": "^0.7.8" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/vizion/node_modules/async": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.14" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -13609,6 +14444,13 @@ "dotenv": "17.4.1", "dotenv-expand": "12.0.3", "lodash": "4.18.1" + }, + "dependencies": { + "dotenv": { + "version": "17.4.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", + "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==" + } } }, "@nestjs/core": { @@ -13779,6 +14621,87 @@ "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", "dev": true }, + "@pm2/blessed": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@pm2/blessed/-/blessed-0.1.81.tgz", + "integrity": "sha512-ZcNHqQjMuNRcQ7Z1zJbFIQZO/BDKV3KbiTckWdfbUaYhj7uNmUwb+FbdDWSCkvxNr9dBJQwvV17o6QBkAvgO0g==" + }, + "@pm2/js-api": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@pm2/js-api/-/js-api-0.8.0.tgz", + "integrity": "sha512-nmWzrA/BQZik3VBz+npRcNIu01kdBhWL0mxKmP1ciF/gTcujPTQqt027N9fc1pK9ERM8RipFhymw7RcmCyOEYA==", + "requires": { + "async": "^2.6.3", + "debug": "~4.3.1", + "eventemitter2": "^6.3.1", + "extrareqp2": "^1.0.0", + "ws": "^7.0.0" + }, + "dependencies": { + "async": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "requires": { + "lodash": "^4.17.14" + } + }, + "debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "requires": { + "ms": "^2.1.3" + } + }, + "ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "requires": {} + } + } + }, + "@pm2/pm2-version-check": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@pm2/pm2-version-check/-/pm2-version-check-1.0.4.tgz", + "integrity": "sha512-SXsM27SGH3yTWKc2fKR4SYNxsmnvuBQ9dd6QHtEWmiZ/VqaOYPAIlS8+vMcn27YLtAEBGvNRSh3TPNvtjZgfqA==", + "requires": { + "debug": "^4.3.1" + } + }, + "@redis/bloom": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.12.1.tgz", + "integrity": "sha512-PUUfv+ms7jgPSBVoo/DN4AkPHj4D5TZSd6SbJX7egzBplkYUcKmHRE8RKia7UtZ8bSQbLguLvxVO+asKtQfZWA==", + "requires": {} + }, + "@redis/client": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz", + "integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==", + "requires": { + "cluster-key-slot": "1.1.2" + } + }, + "@redis/json": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.12.1.tgz", + "integrity": "sha512-eOze75esLve4vfqDel7aMX08CNaiLLQS2fV8mpRN9NxPe1rVR4vQyYiW/OgtGUysF6QOr9ANhfxABKNOJfXdKg==", + "requires": {} + }, + "@redis/search": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.12.1.tgz", + "integrity": "sha512-ItlxbxC9cKI6IU1TLWoczwJCRb6TdmkEpWv05UrPawqaAnWGRu3rcIqsc5vN483T2fSociuyV1UkWIL5I4//2w==", + "requires": {} + }, + "@redis/time-series": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.12.1.tgz", + "integrity": "sha512-c6JL6E3EcZJuNqKFz+KM+l9l5mpcQiKvTwgA3blt5glWJ8hjDk0yeHN3beE/MpqYIQ8UEX44ItQzgkE/gCBELQ==", + "requires": {} + }, "@sapphire/async-queue": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", @@ -13871,6 +14794,11 @@ "optional": true, "peer": true }, + "@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==" + }, "@tsconfig/node10": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", @@ -14580,6 +15508,19 @@ "dev": true, "requires": {} }, + "amp": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/amp/-/amp-0.3.1.tgz", + "integrity": "sha512-OwIuC4yZaRogHKiuU5WlMR5Xk/jAcpPtawWL05Gj8Lvm2F6mwoJt4O/bHI+DHwG79vWd+8OFYM4/BzYqyRd3qw==" + }, + "amp-message": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/amp-message/-/amp-message-0.1.2.tgz", + "integrity": "sha512-JqutcFwoU1+jhv7ArgW38bqrE+LQdcRv4NxNw0mp0JHQyB6tXesWRjtYKlDgHRY2o3JE5UTaBGUK8kSWUdxWUg==", + "requires": { + "amp": "0.3.1" + } + }, "ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -14617,7 +15558,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, "requires": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -14655,8 +15595,7 @@ "argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "array-timsort": { "version": "1.0.3", @@ -14676,6 +15615,19 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "dev": true }, + "ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "requires": { + "tslib": "^2.0.1" + } + }, + "async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==" + }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -14809,24 +15761,38 @@ "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", "dev": true }, + "basic-ftp": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==" + }, "bech32": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz", "integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==" }, "better-sqlite3": { - "version": "12.9.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz", - "integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==", + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", + "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", + "optional": true, + "peer": true, "requires": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, + "binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==" + }, "bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "optional": true, + "peer": true, "requires": { "file-uri-to-path": "1.0.0" } @@ -14874,6 +15840,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "devOptional": true, "requires": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -14884,6 +15851,7 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "devOptional": true, "requires": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -14891,6 +15859,11 @@ } } }, + "bodec": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bodec/-/bodec-0.1.0.tgz", + "integrity": "sha512-Ylo+MAo5BDUq1KA3f3R/MFhh+g8cnHmo8bz3YPGhI1znrMaf77ol1sfvYJzsw3nTE+Y2GryfDxBaR+AqpAkEHQ==" + }, "brace-expansion": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", @@ -14904,7 +15877,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "requires": { "fill-range": "^7.1.1" } @@ -15186,6 +16158,25 @@ "string-width": "^4.2.0" } }, + "cli-tableau": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cli-tableau/-/cli-tableau-2.0.1.tgz", + "integrity": "sha512-he+WTicka9cl0Fg/y+YyxcN6/bfQ/1O3QmgxRXDhABKqLzvoOSM4fMzp39uMyLBulAFuywD2N7UaoQE7WaADxQ==", + "requires": { + "chalk": "3.0.0" + }, + "dependencies": { + "chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, "cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -15208,6 +16199,11 @@ "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", "dev": true }, + "cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==" + }, "co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -15352,6 +16348,11 @@ "luxon": "~3.7.0" } }, + "croner": { + "version": "4.1.97", + "resolved": "https://registry.npmjs.org/croner/-/croner-4.1.97.tgz", + "integrity": "sha512-/f6gpQuxDaqXu+1kwQYSckUglPaOrHdbIlBAu0YuW8/Cdb45XwXYNUBXg3r/9Mo6n540Kn/smKcZWko5x99KrQ==" + }, "cross-env": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", @@ -15370,6 +16371,16 @@ "which": "^2.0.1" } }, + "culvert": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/culvert/-/culvert-0.1.2.tgz", + "integrity": "sha512-yi1x3EAWKjQTreYWeSd98431AV+IEE0qoDyOoaHJ7KJ21gv6HtBXHVLX74opVSGqcR8/AbjJBHAHpcOy2bj5Gg==" + }, + "data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==" + }, "dayjs": { "version": "1.11.20", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", @@ -15387,6 +16398,8 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "optional": true, + "peer": true, "requires": { "mimic-response": "^3.1.0" } @@ -15400,7 +16413,9 @@ "deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==" + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "optional": true, + "peer": true }, "deep-is": { "version": "0.1.4", @@ -15433,6 +16448,16 @@ "gopd": "^1.0.1" } }, + "degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "requires": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + } + }, "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -15453,7 +16478,9 @@ "detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==" + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "optional": true, + "peer": true }, "detect-newline": { "version": "3.1.0", @@ -15527,9 +16554,9 @@ } }, "dotenv": { - "version": "17.4.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", - "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==" + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==" }, "dotenv-expand": { "version": "12.0.3", @@ -15537,13 +16564,6 @@ "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==", "requires": { "dotenv": "^16.4.5" - }, - "dependencies": { - "dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==" - } } }, "dunder-proto": { @@ -15604,6 +16624,8 @@ "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "optional": true, + "peer": true, "requires": { "once": "^1.4.0" } @@ -15681,6 +16703,30 @@ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==" }, + "escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "requires": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2", + "source-map": "~0.6.1" + }, + "dependencies": { + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==" + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "optional": true + } + } + }, "eslint": { "version": "8.57.1", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", @@ -15807,8 +16853,7 @@ "esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" }, "esquery": { "version": "1.5.0", @@ -15853,8 +16898,12 @@ "esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" + }, + "eventemitter2": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", + "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==" }, "events": { "version": "3.3.0", @@ -15888,7 +16937,9 @@ "expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==" + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "optional": true, + "peer": true }, "expect": { "version": "29.7.0", @@ -15903,6 +16954,14 @@ "jest-util": "^29.7.0" } }, + "extrareqp2": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/extrareqp2/-/extrareqp2-1.0.0.tgz", + "integrity": "sha512-Gum0g1QYb6wpPJCVypWP3bbIuaibcFiJcpuPM10YSXp/tzqi84x9PJageob+eN4xVRIOto4wjSGNLyMD54D2xA==", + "requires": { + "follow-redirects": "^1.14.0" + } + }, "fast-decode-uri-component": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", @@ -15932,6 +16991,11 @@ "micromatch": "^4.0.8" } }, + "fast-json-patch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz", + "integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==" + }, "fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -16057,13 +17121,14 @@ "file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "optional": true, + "peer": true }, "fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "requires": { "to-regex-range": "^5.0.1" } @@ -16188,7 +17253,9 @@ "fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "optional": true, + "peer": true }, "fs-extra": { "version": "10.1.0", @@ -16238,7 +17305,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "optional": true }, "function-bind": { @@ -16312,10 +17378,32 @@ "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", "dev": true }, + "get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "requires": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + } + }, + "git-node-fs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/git-node-fs/-/git-node-fs-1.0.0.tgz", + "integrity": "sha512-bLQypt14llVXBg0S0u8q8HmU7g9p3ysH+NvVlae5vILuUvs759665HvmR5+wb04KjHyjFcDRxdYb4kyNnluMUQ==" + }, + "git-sha1": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/git-sha1/-/git-sha1-0.1.2.tgz", + "integrity": "sha512-2e/nZezdVlyCopOCYHeW0onkbZg7xP1Ad6pndPy1rCygeRykefUS6r7oA5cJRGEFvseiaz5a/qUHFVX1dd6Isg==" + }, "github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "optional": true, + "peer": true }, "glob": { "version": "13.0.6", @@ -16368,7 +17456,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "requires": { "is-glob": "^4.0.1" } @@ -16459,8 +17546,7 @@ "has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" }, "has-property-descriptors": { "version": "1.0.2", @@ -16650,9 +17736,7 @@ "ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", - "optional": true, - "peer": true + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==" }, "ipaddr.js": { "version": "2.4.0", @@ -16665,6 +17749,14 @@ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true }, + "is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "requires": { + "binary-extensions": "^2.0.0" + } + }, "is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -16687,8 +17779,7 @@ "is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==" }, "is-fullwidth-code-point": { "version": "3.0.0", @@ -16705,7 +17796,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "requires": { "is-extglob": "^2.1.1" } @@ -16726,8 +17816,7 @@ "is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" }, "is-path-inside": { "version": "3.0.3", @@ -17375,6 +18464,17 @@ } } }, + "js-git": { + "version": "0.7.8", + "resolved": "https://registry.npmjs.org/js-git/-/js-git-0.7.8.tgz", + "integrity": "sha512-+E5ZH/HeRnoc/LW0AmAyhU+mNcWBzAKE+30+IDMLSLbbK+Tdt02AdkOKq9u15rlJsDEGFqtgckc8ZM59LhhiUA==", + "requires": { + "bodec": "^0.1.0", + "culvert": "^0.1.2", + "git-sha1": "^0.1.2", + "pako": "^0.2.5" + } + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -17385,7 +18485,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "requires": { "argparse": "^2.0.1" } @@ -17442,6 +18541,12 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "optional": true + }, "json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -17768,7 +18873,9 @@ "mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==" + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "optional": true, + "peer": true }, "minimatch": { "version": "3.1.5", @@ -17934,7 +19041,9 @@ "mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "optional": true, + "peer": true }, "ms": { "version": "2.1.3", @@ -17950,7 +19059,9 @@ "napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==" + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "optional": true, + "peer": true }, "natural-compare": { "version": "1.4.0", @@ -17977,10 +19088,17 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, + "netmask": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", + "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==" + }, "node-abi": { "version": "3.92.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "optional": true, + "peer": true, "requires": { "semver": "^7.3.5" } @@ -18066,8 +19184,7 @@ "normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" }, "npm-run-path": { "version": "4.0.1", @@ -18200,11 +19317,75 @@ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "dev": true }, + "pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "requires": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "dependencies": { + "agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==" + }, + "http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "requires": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + } + }, + "https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "requires": { + "agent-base": "^7.1.2", + "debug": "4" + } + }, + "socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "requires": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + } + } + } + }, + "pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "requires": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + } + }, "package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==" }, + "pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==" + }, "parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -18334,11 +19515,6 @@ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==" }, - "pg-format": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/pg-format/-/pg-format-1.0.4.tgz", - "integrity": "sha512-YyKEF78pEA6wwTAqOUaHIN/rWpfzzIuMh9KdAhc3rSLQ/7zkRFcCgYBAEGatDstLyZw4g0s9SNICmaTGnBVeyw==" - }, "pg-int8": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", @@ -18355,17 +19531,6 @@ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==" }, - "pg-pubsub": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/pg-pubsub/-/pg-pubsub-0.8.1.tgz", - "integrity": "sha512-b/EHOwCrag4isghc4XgRipeAjfgyNg1DiL3Dwwh1Ojp91Lriltn5eg2nSWjBe4pzcFzhTM6HiB7LOG9NN1nx5g==", - "requires": { - "pg": "^8.7.3", - "pg-format": "^1.0.2", - "pony-cause": "^2.1.8", - "promised-retry": "^0.5.0" - } - }, "pg-types": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", @@ -18395,8 +19560,15 @@ "picomatch": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==" + }, + "pidusage": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pidusage/-/pidusage-4.0.1.tgz", + "integrity": "sha512-yCH2dtLHfEBnzlHUJymR/Z1nN2ePG3m392Mv8TFlTP1B0xkpMQNHAnfkY0n2tAi6ceKO6YWhxYfZ96V4vVkh/g==", + "requires": { + "safe-buffer": "^5.2.1" + } }, "pino": { "version": "10.3.1", @@ -18489,10 +19661,113 @@ "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", "dev": true }, - "pony-cause": { - "version": "2.1.11", - "resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-2.1.11.tgz", - "integrity": "sha512-M7LhCsdNbNgiLYiP4WjsfLUuFmCfnjdF6jKe2R9NKl4WFN+HZPGHJZ9lnLP7f9ZnKe3U9nuWD0szirmj+migUg==" + "pm2": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pm2/-/pm2-7.0.1.tgz", + "integrity": "sha512-h/DI7WomfdAWDzDRtjhnNfsG4Do6kuOY+VGKbqffHW3yRZMUoWzwyBDkXjrOWvnBlwuY3lYBKkozDdCagfRI4Q==", + "requires": { + "@pm2/blessed": "0.1.81", + "@pm2/js-api": "0.8.0", + "@pm2/pm2-version-check": "1.0.4", + "amp": "0.3.1", + "amp-message": "0.1.2", + "ansis": "4.0.0-node10", + "async": "3.2.6", + "chokidar": "3.6.0", + "cli-tableau": "2.0.1", + "commander": "2.15.1", + "croner": "4.1.97", + "dayjs": "1.11.15", + "debug": "4.4.3", + "eventemitter2": "6.4.9", + "fast-json-patch": "3.1.1", + "js-yaml": "4.1.1", + "pidusage": "4.0.1", + "pm2-deploy": "1.0.2", + "pm2-sysmonit": "^1.2.8", + "proxy-agent": "6.5.0", + "semver": "7.7.2", + "vizion": "2.2.1", + "ws": "8.20.0" + }, + "dependencies": { + "ansis": { + "version": "4.0.0-node10", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.0.0-node10.tgz", + "integrity": "sha512-BRrU0Bo1X9dFGw6KgGz6hWrqQuOlVEDOzkb0QSLZY9sXHqA7pNj7yHPVJRz7y/rj4EOJ3d/D5uxH+ee9leYgsg==" + }, + "chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "requires": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "fsevents": "~2.3.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" + } + }, + "commander": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz", + "integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==" + }, + "dayjs": { + "version": "1.11.15", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.15.tgz", + "integrity": "sha512-MC+DfnSWiM9APs7fpiurHGCoeIx0Gdl6QZBy+5lu8MbYKN5FZEXqOgrundfibdfhGZ15o9hzmZ2xJjZnbvgKXQ==" + }, + "readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "requires": { + "picomatch": "^2.2.1" + } + }, + "semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==" + } + } + }, + "pm2-deploy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pm2-deploy/-/pm2-deploy-1.0.2.tgz", + "integrity": "sha512-YJx6RXKrVrWaphEYf++EdOOx9EH18vM8RSZN/P1Y+NokTKqYAca/ejXwVLyiEpNju4HPZEk3Y2uZouwMqUlcgg==", + "requires": { + "run-series": "^1.1.8", + "tv4": "^1.3.0" + } + }, + "pm2-sysmonit": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/pm2-sysmonit/-/pm2-sysmonit-1.2.8.tgz", + "integrity": "sha512-ACOhlONEXdCTVwKieBIQLSi2tQZ8eKinhcr9JpZSUAL8Qy0ajIgRtsLxG/lwPOW3JEKqPyw/UaHmTWhUzpP4kA==", + "optional": true, + "requires": { + "async": "^3.2.0", + "debug": "^4.3.1", + "pidusage": "^2.0.21", + "systeminformation": "^5.7", + "tx2": "~1.0.4" + }, + "dependencies": { + "pidusage": { + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/pidusage/-/pidusage-2.0.21.tgz", + "integrity": "sha512-cv3xAQos+pugVX+BfXpHsbyz/dLzX+lr44zNMsYiGxUw+kV5sgQCIcLd1z+0vq+KyC7dJ+/ts2PsfgWfSC3WXA==", + "optional": true, + "requires": { + "safe-buffer": "^5.2.1" + } + } + } }, "possible-typed-array-names": { "version": "1.1.0", @@ -18526,6 +19801,8 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "optional": true, + "peer": true, "requires": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", @@ -18604,21 +19881,6 @@ "retry": "^0.12.0" } }, - "promised-retry": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/promised-retry/-/promised-retry-0.5.0.tgz", - "integrity": "sha512-jbYvN6UGE+/3E1g0JmgDPchUc+4VI4cBaPjdr2Lso22xfFqut2warEf6IhWuhPJKbJYVOQAyCt2Jx+01ORCItg==", - "requires": { - "pony-cause": "^1.1.1" - }, - "dependencies": { - "pony-cause": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-1.1.1.tgz", - "integrity": "sha512-PxkIc/2ZpLiEzQXu5YRDOUgBlfGYBY8156HY5ZcRAwwonMk5W/MrJP2LLkG/hF7GEQzaHo2aS7ho6ZLCOvf+6g==" - } - } - }, "prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -18629,6 +19891,66 @@ "sisteransi": "^1.0.5" } }, + "proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "requires": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "dependencies": { + "agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==" + }, + "http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "requires": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + } + }, + "https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "requires": { + "agent-base": "^7.1.2", + "debug": "4" + } + }, + "lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==" + }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "requires": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + } + } + } + }, "proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -18638,6 +19960,8 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "optional": true, + "peer": true, "requires": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -18679,6 +20003,8 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "optional": true, + "peer": true, "requires": { "deep-extend": "^0.6.0", "ini": "~1.3.0", @@ -18689,7 +20015,9 @@ "strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==" + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "optional": true, + "peer": true } } }, @@ -18703,6 +20031,7 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "devOptional": true, "requires": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -18728,6 +20057,18 @@ "resolve": "^1.1.6" } }, + "redis": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz", + "integrity": "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==", + "requires": { + "@redis/bloom": "5.12.1", + "@redis/client": "5.12.1", + "@redis/json": "5.12.1", + "@redis/search": "5.12.1", + "@redis/time-series": "5.12.1" + } + }, "reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -18848,6 +20189,11 @@ "queue-microtask": "^1.2.2" } }, + "run-series": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/run-series/-/run-series-1.1.9.tgz", + "integrity": "sha512-Arc4hUN896vjkqCYrUXquBFtRZdv1PfLbTYP71efP6butxyQ0kWpiNJyAgsxscmQg1cqvHY32/UCBzXedTpU2g==" + }, "rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -19045,12 +20391,16 @@ "simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==" + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "optional": true, + "peer": true }, "simple-get": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "optional": true, + "peer": true, "requires": { "decompress-response": "^6.0.0", "once": "^1.3.1", @@ -19072,16 +20422,12 @@ "smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "optional": true, - "peer": true + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==" }, "socks": { "version": "2.8.8", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.8.tgz", "integrity": "sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==", - "optional": true, - "peer": true, "requires": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" @@ -19204,6 +20550,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "devOptional": true, "requires": { "safe-buffer": "~5.2.0" } @@ -19312,7 +20659,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "requires": { "has-flag": "^4.0.0" } @@ -19337,6 +20683,12 @@ "@pkgr/core": "^0.2.9" } }, + "systeminformation": { + "version": "5.31.7", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.7.tgz", + "integrity": "sha512-/8NC53e5nP9nmhn42/ncdOkyJnOoue/Vy+tJOyUGd1Yv66G069wK4rrziwhrqDETgk78CudTQupw5z19S5uoZw==", + "optional": true + }, "tapable": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", @@ -19371,6 +20723,8 @@ "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "optional": true, + "peer": true, "requires": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", @@ -19381,7 +20735,9 @@ "chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "optional": true, + "peer": true } } }, @@ -19389,6 +20745,8 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "optional": true, + "peer": true, "requires": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -19552,7 +20910,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "requires": { "is-number": "^7.0.0" } @@ -19685,10 +21042,26 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "optional": true, + "peer": true, "requires": { "safe-buffer": "^5.0.1" } }, + "tv4": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tv4/-/tv4-1.3.0.tgz", + "integrity": "sha512-afizzfpJgvPr+eDkREK4MxJ/+r8nEEHcmitwgnPUqpaP+FpwQyadnxNoSACbgc/b1LsZYtODGoPiFxQrgJgjvw==" + }, + "tx2": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tx2/-/tx2-1.0.5.tgz", + "integrity": "sha512-sJ24w0y03Md/bxzK4FU8J8JveYYUbSs2FViLJ2D/8bytSiyPRbuE3DyL/9UKYXTZlV3yXq0L8GLlhobTnekCVg==", + "optional": true, + "requires": { + "json-stringify-safe": "^5.0.1" + } + }, "type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -19755,11 +21128,6 @@ "balanced-match": "^1.0.0" } }, - "dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==" - }, "glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -19877,7 +21245,8 @@ "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "devOptional": true }, "v8-compile-cache-lib": { "version": "3.0.1", @@ -19909,6 +21278,27 @@ "safe-buffer": "^5.1.1" } }, + "vizion": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/vizion/-/vizion-2.2.1.tgz", + "integrity": "sha512-sfAcO2yeSU0CSPFI/DmZp3FsFE9T+8913nv1xWBOyzODv13fwkn6Vl7HqxGpkr9F608M+8SuFId3s+BlZqfXww==", + "requires": { + "async": "^2.6.3", + "git-node-fs": "^1.0.0", + "ini": "^1.3.5", + "js-git": "^0.7.8" + }, + "dependencies": { + "async": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "requires": { + "lodash": "^4.17.14" + } + } + } + }, "walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", diff --git a/package.json b/package.json index 43f8ee3..bcc48a5 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,13 @@ "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", + "start:docker": "node dist/scripts/start-docker.js", + "migration:run": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d src/data-source.ts migration:run", + "migration:run:prod": "node dist/scripts/run-migrations.js", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test": "jest", + "test:baseline": "jest --runInBand --json --outputFile=.baseline-jest-results.json", + "test:integration": "jest --config ./test/jest-integration.json --runInBand", "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", @@ -34,7 +39,6 @@ "@nestjs/typeorm": "^11.0.1", "@scure/btc-signer": "^2.2.0", "axios": "^1.16.0", - "better-sqlite3": "^12.9.0", "bitcoin-address-validation": "^2.2.3", "bitcoinjs-lib": "^6.1.7", "bs58": "^5.0.0", @@ -42,9 +46,11 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.4", "discord.js": "^14.26.4", + "dotenv": "^16.6.1", "merkle-lib": "^2.0.10", "pg": "^8.20.0", - "pg-pubsub": "^0.8.1", + "pm2": "^7.0.1", + "redis": "^5.12.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "tiny-secp256k1": "^2.2.4", diff --git a/src/ORM/_migrations/ActiveOnlyUserAgentReport1780860200000.ts b/src/ORM/_migrations/ActiveOnlyUserAgentReport1780860200000.ts new file mode 100644 index 0000000..dfc9ba0 --- /dev/null +++ b/src/ORM/_migrations/ActiveOnlyUserAgentReport1780860200000.ts @@ -0,0 +1,36 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class ActiveOnlyUserAgentReport1780860200000 implements MigrationInterface { + name = 'ActiveOnlyUserAgentReport1780860200000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP MATERIALIZED VIEW IF EXISTS "user_agent_report_view"`); + await queryRunner.query(` + CREATE MATERIALIZED VIEW "user_agent_report_view" AS + SELECT + "userAgent", + COUNT("userAgent") AS "count", + MAX("bestDifficulty") AS "bestDifficulty", + SUM("hashRate") AS "totalHashRate" + FROM "client_entity" + WHERE "deletedAt" IS NULL + GROUP BY "userAgent" + ORDER BY "totalHashRate" DESC + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP MATERIALIZED VIEW IF EXISTS "user_agent_report_view"`); + await queryRunner.query(` + CREATE MATERIALIZED VIEW "user_agent_report_view" AS + SELECT + "userAgent", + COUNT("userAgent") AS "count", + MAX("bestDifficulty") AS "bestDifficulty", + SUM("hashRate") AS "totalHashRate" + FROM "client_entity" + GROUP BY "userAgent" + ORDER BY "totalHashRate" DESC + `); + } +} diff --git a/src/ORM/_migrations/InitialTimescaleSchema1780859300000.ts b/src/ORM/_migrations/InitialTimescaleSchema1780859300000.ts new file mode 100644 index 0000000..e0f4a77 --- /dev/null +++ b/src/ORM/_migrations/InitialTimescaleSchema1780859300000.ts @@ -0,0 +1,215 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class InitialTimescaleSchema1780859300000 implements MigrationInterface { + public name = 'InitialTimescaleSchema1780859300000'; + public transaction = false; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`); + await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS timescaledb`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "address_settings_entity" ( + "address" varchar(62) PRIMARY KEY, + "shares" integer NOT NULL DEFAULT 0, + "bestDifficulty" numeric NOT NULL DEFAULT 0, + "bestDifficultyUserAgent" varchar, + "miscCoinbaseScriptData" varchar, + "deletedAt" timestamptz, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now() + ) + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "client_entity" ( + "id" uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + "address" varchar(62) NOT NULL, + "clientName" varchar(64) NOT NULL, + "sessionId" varchar(8) NOT NULL, + "userAgent" varchar(128), + "startTime" timestamptz NOT NULL, + "bestDifficulty" numeric NOT NULL DEFAULT 0, + "hashRate" numeric NOT NULL DEFAULT 0, + "deletedAt" timestamptz, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now() + ) + `); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_client_entity_address" ON "client_entity" ("address")`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_client_cleanup" ON "client_entity" ("id") WHERE "deletedAt" IS NULL`); + await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_unique_nonce" ON "client_entity" ("sessionId") WHERE "deletedAt" IS NOT NULL`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "accepted_share_entity" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "acceptedAt" timestamptz NOT NULL, + "protocol" varchar(8) NOT NULL, + "address" varchar(62) NOT NULL, + "clientName" varchar NOT NULL, + "sessionId" varchar(8) NOT NULL, + "clientId" uuid NOT NULL, + "jobId" varchar NOT NULL, + "jobTemplateId" varchar NOT NULL, + "blockHeight" bigint NOT NULL, + "creditedDifficulty" numeric NOT NULL, + "submissionDifficulty" numeric NOT NULL, + "networkDifficulty" numeric NOT NULL, + "nonce" varchar NOT NULL, + "ntime" varchar NOT NULL, + "version" varchar NOT NULL, + "extraNonce2" varchar NOT NULL, + "isBlockCandidate" boolean NOT NULL DEFAULT false, + "blockSubmissionResult" text, + "createdAt" timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY ("id", "acceptedAt") + ) + `); + await queryRunner.query(` + SELECT create_hypertable( + '"accepted_share_entity"', + 'acceptedAt', + chunk_time_interval => INTERVAL '1 day', + if_not_exists => TRUE + ) + `); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_accepted_share_accounting_lookup" ON "accepted_share_entity" ("address", "clientName", "acceptedAt" DESC)`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_accepted_share_client_lookup" ON "accepted_share_entity" ("clientId", "acceptedAt" DESC)`); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "IDX_accepted_share_unique_submission" + ON "accepted_share_entity" ("acceptedAt", "protocol", "sessionId", "jobId", "nonce", "ntime", "version", "extraNonce2") + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "blocks_entity" ( + "id" bigserial PRIMARY KEY, + "height" integer NOT NULL, + "minerAddress" varchar(62) NOT NULL, + "worker" varchar NOT NULL, + "sessionId" varchar(8) NOT NULL, + "blockData" varchar NOT NULL, + "deletedAt" timestamptz, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now() + ) + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "rpc_block_entity" ( + "blockHeight" integer PRIMARY KEY, + "data" varchar + ) + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "telegram_subscriptions_entity" ( + "id" bigserial PRIMARY KEY, + "address" varchar(62) NOT NULL, + "telegramChatId" integer NOT NULL, + "deletedAt" timestamptz, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now() + ) + `); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_telegram_subscriptions_address" ON "telegram_subscriptions_entity" ("address")`); + + await queryRunner.query(` + CREATE MATERIALIZED VIEW IF NOT EXISTS "user_agent_report_view" AS + SELECT + "userAgent", + COUNT("userAgent") AS "count", + MAX("bestDifficulty") AS "bestDifficulty", + SUM("hashRate") AS "totalHashRate" + FROM "client_entity" + WHERE "deletedAt" IS NULL + GROUP BY "userAgent" + ORDER BY "totalHashRate" DESC + `); + + await queryRunner.query(` + CREATE MATERIALIZED VIEW IF NOT EXISTS "accepted_share_10m" + WITH (timescaledb.continuous) AS + SELECT + time_bucket(INTERVAL '10 minutes', "acceptedAt") AS "bucket", + "address", + "clientName", + "sessionId", + "clientId", + SUM("creditedDifficulty") AS "shares", + COUNT(*) AS "acceptedCount", + ROUND(((SUM("creditedDifficulty") * 4294967296) / 600)) AS "hashRate" + FROM "accepted_share_entity" + GROUP BY "bucket", "address", "clientName", "sessionId", "clientId" + WITH NO DATA + `); + + await queryRunner.query(` + CREATE MATERIALIZED VIEW IF NOT EXISTS "accepted_share_1h" + WITH (timescaledb.continuous) AS + SELECT + time_bucket(INTERVAL '1 hour', "acceptedAt") AS "bucket", + "address", + "clientName", + SUM("creditedDifficulty") AS "shares", + COUNT(*) AS "acceptedCount" + FROM "accepted_share_entity" + GROUP BY "bucket", "address", "clientName" + WITH NO DATA + `); + + await queryRunner.query(` + CREATE MATERIALIZED VIEW IF NOT EXISTS "accepted_share_1d" + WITH (timescaledb.continuous) AS + SELECT + time_bucket(INTERVAL '1 day', "acceptedAt") AS "bucket", + "address", + SUM("creditedDifficulty") AS "shares", + COUNT(*) AS "acceptedCount" + FROM "accepted_share_entity" + GROUP BY "bucket", "address" + WITH NO DATA + `); + + await queryRunner.query(` + SELECT add_continuous_aggregate_policy( + 'accepted_share_10m', + start_offset => INTERVAL '2 days', + end_offset => INTERVAL '1 minute', + schedule_interval => INTERVAL '1 minute', + if_not_exists => TRUE + ) + `); + await queryRunner.query(` + SELECT add_continuous_aggregate_policy( + 'accepted_share_1h', + start_offset => INTERVAL '30 days', + end_offset => INTERVAL '10 minutes', + schedule_interval => INTERVAL '10 minutes', + if_not_exists => TRUE + ) + `); + await queryRunner.query(` + SELECT add_continuous_aggregate_policy( + 'accepted_share_1d', + start_offset => INTERVAL '365 days', + end_offset => INTERVAL '1 hour', + schedule_interval => INTERVAL '1 hour', + if_not_exists => TRUE + ) + `); + await queryRunner.query(`SELECT add_retention_policy('accepted_share_entity', INTERVAL '90 days', if_not_exists => TRUE)`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP MATERIALIZED VIEW IF EXISTS "accepted_share_1d"`); + await queryRunner.query(`DROP MATERIALIZED VIEW IF EXISTS "accepted_share_1h"`); + await queryRunner.query(`DROP MATERIALIZED VIEW IF EXISTS "accepted_share_10m"`); + await queryRunner.query(`DROP MATERIALIZED VIEW IF EXISTS "user_agent_report_view"`); + await queryRunner.query(`DROP TABLE IF EXISTS "accepted_share_entity" CASCADE`); + await queryRunner.query(`DROP TABLE IF EXISTS "telegram_subscriptions_entity"`); + await queryRunner.query(`DROP TABLE IF EXISTS "rpc_block_entity"`); + await queryRunner.query(`DROP TABLE IF EXISTS "blocks_entity"`); + await queryRunner.query(`DROP TABLE IF EXISTS "client_entity"`); + await queryRunner.query(`DROP TABLE IF EXISTS "address_settings_entity"`); + } +} diff --git a/src/ORM/_migrations/TimescaleOperationalHardening1780861200000.ts b/src/ORM/_migrations/TimescaleOperationalHardening1780861200000.ts new file mode 100644 index 0000000..98c1cfb --- /dev/null +++ b/src/ORM/_migrations/TimescaleOperationalHardening1780861200000.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class TimescaleOperationalHardening1780861200000 implements MigrationInterface { + public name = 'TimescaleOperationalHardening1780861200000'; + public transaction = false; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "accepted_share_entity" SET ( + timescaledb.compress, + timescaledb.compress_orderby = '"acceptedAt" DESC', + timescaledb.compress_segmentby = '"address","clientName"' + ) + `); + + await queryRunner.query(` + SELECT add_compression_policy( + 'accepted_share_entity', + INTERVAL '1 day', + if_not_exists => TRUE + ) + `); + + await queryRunner.query(`SELECT remove_retention_policy('accepted_share_entity', if_exists => TRUE)`); + await queryRunner.query(`SELECT add_retention_policy('accepted_share_entity', INTERVAL '30 days', if_not_exists => TRUE)`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`SELECT remove_compression_policy('accepted_share_entity', if_exists => TRUE)`); + await queryRunner.query(`SELECT remove_retention_policy('accepted_share_entity', if_exists => TRUE)`); + await queryRunner.query(`SELECT add_retention_policy('accepted_share_entity', INTERVAL '90 days', if_not_exists => TRUE)`); + } +} diff --git a/src/ORM/_migrations/UniqueNonceIndex.ts b/src/ORM/_migrations/UniqueNonceIndex.ts index 3182338..ae78979 100644 --- a/src/ORM/_migrations/UniqueNonceIndex.ts +++ b/src/ORM/_migrations/UniqueNonceIndex.ts @@ -4,12 +4,12 @@ export class UniqueNonceIndex implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( - `CREATE UNIQUE INDEX "IDX_unique_nonce" ON "client_entity" ("sessionId") WHERE "deletedAt" IS NOT NULL` + `CREATE UNIQUE INDEX IF NOT EXISTS "IDX_unique_nonce" ON "client_entity" ("sessionId") WHERE "deletedAt" IS NOT NULL` ); } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "IDX_unique_nonce"`); + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_unique_nonce"`); } -} \ No newline at end of file +} diff --git a/src/ORM/_views/user-agent-report/user-agent-report.module.ts b/src/ORM/_views/user-agent-report/user-agent-report.module.ts index 3b3d6ba..15b992e 100644 --- a/src/ORM/_views/user-agent-report/user-agent-report.module.ts +++ b/src/ORM/_views/user-agent-report/user-agent-report.module.ts @@ -3,13 +3,14 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { UserAgentReportService } from './user-agent-report.service'; import { UserAgentReportView } from './user-agent-report.view'; +import { ClientEntity } from '../../client/client.entity'; @Global() @Module({ - imports: [TypeOrmModule.forFeature([UserAgentReportView])], + imports: [TypeOrmModule.forFeature([UserAgentReportView, ClientEntity])], providers: [UserAgentReportService], exports: [TypeOrmModule, UserAgentReportService], }) -export class UserAgentReportModule { } \ No newline at end of file +export class UserAgentReportModule { } diff --git a/src/ORM/_views/user-agent-report/user-agent-report.service.ts b/src/ORM/_views/user-agent-report/user-agent-report.service.ts index a476be1..ff9622b 100644 --- a/src/ORM/_views/user-agent-report/user-agent-report.service.ts +++ b/src/ORM/_views/user-agent-report/user-agent-report.service.ts @@ -2,7 +2,10 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { ClientEntity } from '../../client/client.entity'; +import { ShareAccountingService } from '../../share-accounting/share-accounting.service'; import { UserAgentReportView } from './user-agent-report.view'; +import { RedisMessagingService } from '../../../services/redis-messaging.service'; @Injectable() export class UserAgentReportService { @@ -10,26 +13,64 @@ export class UserAgentReportService { constructor( @InjectRepository(UserAgentReportView) private userAgentReport: Repository, + @InjectRepository(ClientEntity) + private clientRepository: Repository, + private redisMessagingService: RedisMessagingService, + private shareAccountingService: ShareAccountingService, ) { } public async getReport() { - return await this.userAgentReport.find(); + const presences = await this.redisMessagingService.getAllClientPresence(); + const shareSummaries = await this.shareAccountingService.getSessionSummaries( + presences.map(presence => presence.clientId), + ); + const rows = new Map(); + + presences.forEach(presence => { + const userAgent = presence.userAgent == null || presence.userAgent.length === 0 + ? 'Other' + : presence.userAgent; + const row = rows.get(userAgent) ?? { + userAgent, + count: 0, + bestDifficulty: 0, + totalHashRate: 0, + }; + const shareSummary = shareSummaries.get(presence.clientId); + row.count++; + row.bestDifficulty = Math.max( + row.bestDifficulty, + Number(presence.bestDifficulty ?? 0), + Number(shareSummary?.bestSubmissionDifficulty ?? 0), + ); + row.totalHashRate += Number(shareSummary?.hashRateLast10Minutes ?? presence.hashRate ?? 0); + rows.set(userAgent, row); + }); + + return [...rows.values()] + .sort((left, right) => right.totalHashRate - left.totalHashRate) + .map(row => ({ + userAgent: row.userAgent, + count: row.count.toString(), + bestDifficulty: row.bestDifficulty, + totalHashRate: row.totalHashRate.toString(), + })); } public async refreshReport() { try { - return await this.userAgentReport.query(` - COMMIT; - BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ; - REFRESH MATERIALIZED VIEW user_agent_report_view; - COMMIT; - `); + return await this.userAgentReport.query(`REFRESH MATERIALIZED VIEW user_agent_report_view`); } catch (e) { console.log(e) } } -} \ No newline at end of file +} diff --git a/src/ORM/_views/user-agent-report/user-agent-report.view.ts b/src/ORM/_views/user-agent-report/user-agent-report.view.ts index 8fc2dd9..b3c6f49 100644 --- a/src/ORM/_views/user-agent-report/user-agent-report.view.ts +++ b/src/ORM/_views/user-agent-report/user-agent-report.view.ts @@ -12,6 +12,7 @@ import { ClientEntity } from '../../client/client.entity'; .addSelect('MAX(client.bestDifficulty)', 'bestDifficulty') .addSelect('SUM(client.hashRate)', 'totalHashRate') .from(ClientEntity, 'client') + .where('client.deletedAt IS NULL') .groupBy('client.userAgent') .orderBy('"totalHashRate"', 'DESC') }) @@ -24,4 +25,4 @@ export class UserAgentReportView { bestDifficulty: number; @ViewColumn() totalHashRate: string; -} \ No newline at end of file +} diff --git a/src/ORM/accepted-share/accepted-share.entity.ts b/src/ORM/accepted-share/accepted-share.entity.ts new file mode 100644 index 0000000..cb53662 --- /dev/null +++ b/src/ORM/accepted-share/accepted-share.entity.ts @@ -0,0 +1,67 @@ +import { Column, Entity, Index, PrimaryColumn, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity() +@Index('IDX_accepted_share_accounting_lookup', ['address', 'clientName', 'acceptedAt']) +@Index('IDX_accepted_share_client_lookup', ['clientId', 'acceptedAt']) +@Index('IDX_accepted_share_unique_submission', ['acceptedAt', 'protocol', 'sessionId', 'jobId', 'nonce', 'ntime', 'version', 'extraNonce2'], { unique: true }) +export class AcceptedShareEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @PrimaryColumn({ type: 'timestamptz' }) + acceptedAt: Date; + + @Column({ length: 8, type: 'varchar' }) + protocol: 'sv1' | 'sv2'; + + @Column({ length: 62, type: 'varchar' }) + address: string; + + @Column() + clientName: string; + + @Column({ length: 8, type: 'varchar' }) + sessionId: string; + + @Column({ type: 'uuid' }) + clientId: string; + + @Column() + jobId: string; + + @Column() + jobTemplateId: string; + + @Column({ type: 'bigint' }) + blockHeight: number; + + @Column({ type: 'decimal' }) + creditedDifficulty: number; + + @Column({ type: 'decimal' }) + submissionDifficulty: number; + + @Column({ type: 'decimal' }) + networkDifficulty: number; + + @Column() + nonce: string; + + @Column() + ntime: string; + + @Column() + version: string; + + @Column() + extraNonce2: string; + + @Column({ default: false }) + isBlockCandidate: boolean; + + @Column({ type: 'text', nullable: true }) + blockSubmissionResult?: string; + + @Column({ type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + createdAt: Date; +} diff --git a/src/ORM/client-statistics/client-statistics.entity.ts b/src/ORM/client-statistics/client-statistics.entity.ts deleted file mode 100644 index 931d4d7..0000000 --- a/src/ORM/client-statistics/client-statistics.entity.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Column, Entity, Index, ManyToOne } from 'typeorm'; - -import { ClientEntity } from '../client/client.entity'; -import { PrimaryGeneratedBigIntColumn } from '../utils/PrimaryGeneratedBigIntColumn'; -import { TrackedEntity } from '../utils/TrackedEntity.entity'; - -@Entity() -//Index for statistics save -@Index(["clientId", "time"]) -export class ClientStatisticsEntity extends TrackedEntity { - - @PrimaryGeneratedBigIntColumn() - id: number; - - @Column({ length: 62, type: 'varchar' }) - address: string; - - @Column() - clientName: string; - - @Column({ length: 8, type: 'varchar' }) - sessionId: string; - - @Index() - @Column({ type: 'bigint' }) - time: number; - - @Column({ type: 'decimal' }) - shares: number; - - @Column({ default: 0, type: 'bigint' }) - acceptedCount: number; - - - - @ManyToOne( - () => ClientEntity, - clientEntity => clientEntity.statistics, - { nullable: false, } - ) - client: ClientEntity; - - @Index() - @Column({ name: 'clientId' }) - public clientId: string; - - -} diff --git a/src/ORM/client-statistics/client-statistics.module.ts b/src/ORM/client-statistics/client-statistics.module.ts index 59ab63f..50084d5 100644 --- a/src/ORM/client-statistics/client-statistics.module.ts +++ b/src/ORM/client-statistics/client-statistics.module.ts @@ -1,14 +1,11 @@ import { Global, Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { ClientStatisticsEntity } from './client-statistics.entity'; import { ClientStatisticsService } from './client-statistics.service'; @Global() @Module({ - imports: [TypeOrmModule.forFeature([ClientStatisticsEntity])], providers: [ClientStatisticsService], - exports: [TypeOrmModule, ClientStatisticsService], + exports: [ClientStatisticsService], }) -export class ClientStatisticsModule { } \ No newline at end of file +export class ClientStatisticsModule { } diff --git a/src/ORM/client-statistics/client-statistics.service.ts b/src/ORM/client-statistics/client-statistics.service.ts index 2d35502..8cbfe28 100644 --- a/src/ORM/client-statistics/client-statistics.service.ts +++ b/src/ORM/client-statistics/client-statistics.service.ts @@ -1,264 +1,129 @@ import { Injectable } from '@nestjs/common'; -import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; -import { DataSource, Repository } from 'typeorm'; - -import { ClientStatisticsEntity } from './client-statistics.entity'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +const HASHES_PER_DIFFICULTY = 4294967296; +const CHART_BUCKET_SECONDS = 600; +const CHART_WINDOW = '24 hours'; +const SITE_CHART_WINDOW = '7 days'; +const REALTIME_WINDOW = '20 minutes'; @Injectable() export class ClientStatisticsService { - private bulkAsyncUpdates: { - [key: string]: Partial - } = {}; - constructor( - @InjectDataSource() private dataSource: DataSource, - @InjectRepository(ClientStatisticsEntity) - private clientStatisticsRepository: Repository, ) { } - // public async update(clientStatistic: Partial) { - - // await this.clientStatisticsRepository.update({ clientId: clientStatistic.clientId, time: clientStatistic.time }, - // { - // shares: clientStatistic.shares, - // acceptedCount: clientStatistic.acceptedCount, - // updatedAt: new Date() - // }); - // } - - public updateBulkAsync(clientStatistic: Partial) { - const key = clientStatistic.clientId + clientStatistic.time.toString(); - if(this.bulkAsyncUpdates[key] != null){ - this.bulkAsyncUpdates[key].shares = clientStatistic.shares; - this.bulkAsyncUpdates[key].acceptedCount = clientStatistic.acceptedCount; - return; - } - - this.bulkAsyncUpdates[clientStatistic.clientId + clientStatistic.time.toString()] = clientStatistic; + public async getChartDataForSite(limit: number = 144 * 7) { + return this.getAcceptedShareChartData('', [], limit, SITE_CHART_WINDOW); } - public async doBulkAsyncUpdate(){ - if(Object.keys(this.bulkAsyncUpdates).length < 1){ - console.log('No client stats to update.') - return; - } - - // Step 1: Prepare data for bulk update - const values = Object.entries(this.bulkAsyncUpdates).map(([key, value]) => { - return `('${value.clientId}', ${value.time}, ${value.shares}, ${value.acceptedCount}, NOW())` - }).join(','); - - - const query = ` - DO $$ - BEGIN - CREATE TEMP TABLE temp_stats ( - "clientId" UUID, - time BIGINT, - shares NUMERIC, - "acceptedCount" INT, - "updatedAt" TIMESTAMP - ) ON COMMIT DROP; - - INSERT INTO temp_stats ("clientId", time, shares, "acceptedCount", "updatedAt") - VALUES ${values}; - - UPDATE "client_statistics_entity" cse - SET shares = ts.shares, - "acceptedCount" = ts."acceptedCount", - "updatedAt" = ts."updatedAt" - FROM temp_stats ts - WHERE cse."clientId" = ts."clientId" AND cse.time = ts.time; - END; - $$; - `; - - try { - await this.clientStatisticsRepository.query(query); - //console.log(`Bulk updated ${Object.keys(this.bulkAsyncUpdates).length} statistics`) - } catch (error) { - console.error('Bulk update failed:', error.message, query); - throw error; - } - - this.bulkAsyncUpdates = {}; - } - - public async insert(clientStatistic: Partial) { - await this.clientStatisticsRepository.insert(clientStatistic); - } - - public async deleteOldStatistics() { - const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); - - return await this.clientStatisticsRepository - .createQueryBuilder() - .delete() - .from(ClientStatisticsEntity) - .where('time < :time', { time: oneDayAgo.getTime() }) - .execute(); - } - - - public async getChartDataForAddress(address: string) { - - var yesterday = new Date(new Date().getTime() - (24 * 60 * 60 * 1000)); - - const query = ` - SELECT - time AS label, - (SUM(shares) * 4294967296) / 600 AS data - FROM - client_statistics_entity AS entry - WHERE - entry.address = $1 AND entry.time > $2 - GROUP BY - time - ORDER BY - time - LIMIT 144; - - `; - - const result = await this.clientStatisticsRepository.query(query, [address, yesterday.getTime()]); - - return result.map(res => { - res.label = new Date(parseInt(res.label)).toISOString(); - return res; - }).slice(0, result.length - 1); - - + return this.getAcceptedShareChartData( + 'AND "address" = $1', + [address], + 144, + CHART_WINDOW, + ); } - public async getHashRateForGroup(address: string, clientName: string) { - - var oneHour = new Date(new Date().getTime() - (60 * 60 * 1000)); - - const query = ` + const result = await this.dataSource.query(` SELECT - SUM(entry.shares) AS difficultySum - FROM - client_statistics_entity AS entry - WHERE - entry.address = $1 AND entry.clientName = $2 AND entry.time > ${oneHour.getTime()} - `; - - const result = await this.clientStatisticsRepository.query(query, [address, clientName]); - - - const difficultySum = result[0].difficultySum; - - return (difficultySum * 4294967296) / (600); + COALESCE((SUM("creditedDifficulty") * ${HASHES_PER_DIFFICULTY}) / ${CHART_BUCKET_SECONDS}, 0) AS "hashRate" + FROM "accepted_share_entity" + WHERE "address" = $1 + AND "clientName" = $2 + AND "acceptedAt" > NOW() - INTERVAL '1 hour' + `, [address, clientName]); + return parseFloat(result[0]?.hashRate ?? '0'); } public async getChartDataForGroup(address: string, clientName: string) { - var yesterday = new Date(new Date().getTime() - (24 * 60 * 60 * 1000)); - - const query = ` - SELECT - time AS label, - (SUM(shares) * 4294967296) / 600 AS data - FROM - client_statistics_entity AS entry - WHERE - entry.address = $1 AND entry."clientName" = $2 AND entry.time > ${yesterday.getTime()} - GROUP BY - time - ORDER BY - time - LIMIT 144; - `; - - const result = await this.clientStatisticsRepository.query(query, [address, clientName]); - - return result.map(res => { - res.label = new Date(parseInt(res.label)).toISOString(); - return res; - }).slice(0, result.length - 1); - - + return this.getAcceptedShareChartData( + 'AND "address" = $1 AND "clientName" = $2', + [address, clientName], + 144, + CHART_WINDOW, + ); } - - // public async getHashRateForSession(clientId: string) { - - // const query = ` - // SELECT - // "createdAt", - // "updatedAt", - // shares - // FROM - // client_statistics_entity AS entry - // WHERE - // entry."clientId" = $1 - // ORDER BY time DESC - // LIMIT 2; - // `; - - // const result = await this.clientStatisticsRepository.query(query, [clientId]); - - // if (result.length < 1) { - // return 0; - // } - - // const latestStat = result[0]; - - // if (result.length < 2) { - // const time = new Date(latestStat.updatedAt).getTime() - new Date(latestStat.createdAt).getTime(); - // // 1min - // if (time < 1000 * 60) { - // return 0; - // } - // return (parseFloat(latestStat.shares) * 4294967296) / (time / 1000); - // } else { - // const secondLatestStat = result[1]; - // const time = new Date(latestStat.updatedAt).getTime() - new Date(secondLatestStat.createdAt).getTime(); - // // 1min - // if (time < 1000 * 60) { - // return 0; - // } - // return ((parseFloat(latestStat.shares) + parseFloat(secondLatestStat.shares)) * 4294967296) / (time / 1000); - // } - - // } - public async getChartDataForSession(clientId: string) { - var yesterday = new Date(new Date().getTime() - (24 * 60 * 60 * 1000)); + return this.getAcceptedShareChartData( + 'AND "clientId" = $1', + [clientId], + 144, + CHART_WINDOW, + ); + } + private async getAcceptedShareChartData(filterSql: string, params: unknown[], limit: number, windowSql: string) { const query = ` + WITH bounds AS ( + SELECT + NOW() - INTERVAL '${windowSql}' AS since, + time_bucket(INTERVAL '10 minutes', NOW() - INTERVAL '${REALTIME_WINDOW}') AS realtime_start + ), + aggregate_rows AS ( + SELECT + "bucket", + SUM("shares") AS "shares", + SUM("acceptedCount") AS "acceptedCount" + FROM "accepted_share_10m", bounds + WHERE "bucket" > bounds.since + AND "bucket" < bounds.realtime_start + ${filterSql} + GROUP BY "bucket" + ), + realtime_rows AS ( + SELECT + time_bucket(INTERVAL '10 minutes', "acceptedAt") AS "bucket", + SUM("creditedDifficulty") AS "shares", + COUNT(*) AS "acceptedCount" + FROM "accepted_share_entity", bounds + WHERE "acceptedAt" > bounds.since + AND "acceptedAt" >= bounds.realtime_start + ${filterSql} + GROUP BY "bucket" + ), + combined_rows AS ( + SELECT * FROM aggregate_rows + UNION ALL + SELECT * FROM realtime_rows + ) SELECT - time AS label, - (SUM(shares) * 4294967296) / 600 AS data - FROM - client_statistics_entity AS entry - WHERE - entry."clientId" = $1 AND entry.time > ${yesterday.getTime()} - GROUP BY - time - ORDER BY - time - LIMIT 144; + "label", + "data", + "shares", + "acceptedCount" + FROM ( + SELECT + "bucket" AS "label", + ROUND((SUM("shares") * ${HASHES_PER_DIFFICULTY}) / ${CHART_BUCKET_SECONDS}) AS "data", + SUM("shares") AS "shares", + SUM("acceptedCount") AS "acceptedCount" + FROM combined_rows + GROUP BY "bucket" + ORDER BY "bucket" DESC + LIMIT ${limit} + ) AS limited_rows + ORDER BY "label" `; - const result = await this.clientStatisticsRepository.query(query, [clientId]); + const result = await this.dataSource.query(query, params); return result.map(res => { - res.label = new Date(parseInt(res.label)).toISOString(); - return res; - }).slice(0, result.length - 1); - - } - - public async deleteAll() { - return await this.clientStatisticsRepository.clear() + return { + label: new Date(res.label).toISOString(), + data: res.data, + shares: Number(res.shares ?? 0), + acceptedCount: Number(res.acceptedCount ?? 0), + }; + }); } } diff --git a/src/ORM/client/client.entity.ts b/src/ORM/client/client.entity.ts index 7087a7f..1cece70 100644 --- a/src/ORM/client/client.entity.ts +++ b/src/ORM/client/client.entity.ts @@ -1,6 +1,5 @@ -import { Column, Entity, Index, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; -import { ClientStatisticsEntity } from '../client-statistics/client-statistics.entity'; import { TrackedEntity } from '../utils/TrackedEntity.entity'; @@ -39,10 +38,4 @@ export class ClientEntity extends TrackedEntity { @Column({ default: 0, type: 'decimal' }) hashRate: number; - @OneToMany( - () => ClientStatisticsEntity, - clientStatisticsEntity => clientStatisticsEntity.client - ) - statistics: ClientStatisticsEntity[] - } diff --git a/src/ORM/client/client.service.ts b/src/ORM/client/client.service.ts index 0f274c9..40fab6c 100644 --- a/src/ORM/client/client.service.ts +++ b/src/ORM/client/client.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; -import { DataSource, Repository } from 'typeorm'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; import { ClientEntity } from './client.entity'; @@ -9,106 +9,13 @@ import { ClientEntity } from './client.entity'; @Injectable() export class ClientService { - private heartbeatBulkUpdate: { [id: string]: { id: string, hashRate: number, updatedAt: Date } } = {}; - constructor( - @InjectDataSource() - private dataSource: DataSource, @InjectRepository(ClientEntity) private clientRepository: Repository ) { } - // client.service.ts - public async killDeadClients(): Promise { - const BATCH_SIZE = 10000; - - return this.dataSource.transaction('READ COMMITTED', async (manager) => { // ← CHANGE HERE - const deadClients: { id: string }[] = await manager.query(` - SELECT id - FROM client_entity - WHERE "deletedAt" IS NULL - AND "updatedAt" < NOW() - INTERVAL '5 minutes' - ORDER BY id - LIMIT $1 - FOR UPDATE SKIP LOCKED - `, [BATCH_SIZE]); - - if (deadClients.length === 0) { - return false; - } - - await manager - .createQueryBuilder() - .update(ClientEntity) - .set({ deletedAt: () => 'NOW()' }) - .whereInIds(deadClients.map(c => c.id)) - .execute(); - - console.log(`Killed ${deadClients.length} dead clients`); - - return true; - }); - } - - //public async heartbeat(id, hashRate: number, updatedAt: Date) { - // return await this.clientRepository.update({ id }, { hashRate, deletedAt: null, updatedAt }); - // } - - public heartbeatBulkAsync(id, hashRate: number, updatedAt: Date) { - if (this.heartbeatBulkUpdate[id] != null) { - this.heartbeatBulkUpdate[id].hashRate = hashRate; - this.heartbeatBulkUpdate[id].updatedAt = updatedAt; - return; - } - this.heartbeatBulkUpdate[id] = { id, hashRate, updatedAt }; - } - - public async doBulkHeartbeatUpdate() { - if (Object.keys(this.heartbeatBulkUpdate).length < 1) { - console.log('No heartbeats to update.') - return; - } - - const values = Object.entries(this.heartbeatBulkUpdate).map(([key, value]) => { - return `('${value.id}', ${value.hashRate}, NOW())` - }).join(','); - - const query = ` - DO $$ - BEGIN - CREATE TEMP TABLE temp_heartbeats ( - id UUID, - "hashRate" DECIMAL, - "updatedAt" TIMESTAMP - ) ON COMMIT DROP; - - INSERT INTO temp_heartbeats (id, "hashRate", "updatedAt") - VALUES ${values}; - - UPDATE "client_entity" ce - SET "hashRate" = th."hashRate", - "deletedAt" = NULL, - "updatedAt" = th."updatedAt" - FROM temp_heartbeats th - WHERE ce.id = th.id; - END; - $$; - `; - - try { - await this.clientRepository.query(query); - console.log(`Bulk updated ${Object.keys(this.heartbeatBulkUpdate).length} client heartbeats`); - } catch (error) { - console.error('Bulk heartbeat failed:', error.message, 'Query:', query); - throw error; - } - - this.heartbeatBulkUpdate = {}; - } - - public async insert(partialClient: Partial): Promise { const insertResult = await this.clientRepository.insert(partialClient); diff --git a/src/ORM/home-graph/home-graph.entity.ts b/src/ORM/home-graph/home-graph.entity.ts deleted file mode 100644 index c6da8e4..0000000 --- a/src/ORM/home-graph/home-graph.entity.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Column, Entity } from 'typeorm'; - -import { PrimaryGeneratedBigIntColumn } from '../utils/PrimaryGeneratedBigIntColumn'; - -@Entity() -export class HomeGraphEntity { - - @PrimaryGeneratedBigIntColumn() - id: number; - - @Column({ type: 'bigint' }) - label: number; - - @Column({ type: 'bigint' }) - data: number; -} diff --git a/src/ORM/home-graph/home-graph.module.ts b/src/ORM/home-graph/home-graph.module.ts deleted file mode 100644 index 6c9a4a6..0000000 --- a/src/ORM/home-graph/home-graph.module.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Global, Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; - -import { HomeGraphEntity } from './home-graph.entity'; -import { HomeGraphService } from './home-graph.service'; - - - -@Global() -@Module({ - imports: [TypeOrmModule.forFeature([HomeGraphEntity])], - providers: [HomeGraphService], - exports: [TypeOrmModule, HomeGraphService], -}) -export class HomeGraphModule { } \ No newline at end of file diff --git a/src/ORM/home-graph/home-graph.service.ts b/src/ORM/home-graph/home-graph.service.ts deleted file mode 100644 index 6175c96..0000000 --- a/src/ORM/home-graph/home-graph.service.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; - -import { HomeGraphEntity } from './home-graph.entity'; - -@Injectable() -export class HomeGraphService { - constructor( - @InjectRepository(HomeGraphEntity) private homeGraphRepository: Repository - ) { - - } - - public async getLatestTime(): Promise { - const result = await this.homeGraphRepository.createQueryBuilder('entity') - .select('MAX(entity.label)', 'maxNumber') - .getRawOne(); - - if (result.maxNumber == null) { - return new Date(new Date().getTime() - (24 * 60 * 60 * 1000)); - } - - return new Date(parseInt(result.maxNumber)); - } - - public async save(graph: { label: number, data: number }[]) { - await this.homeGraphRepository.insert(graph); - } - - public async getChartDataForSite(limit: number = 144 * 7) { - const records = await this.homeGraphRepository - .createQueryBuilder('homeGraph') - .orderBy('homeGraph.id', 'DESC') - .limit(limit) - .getRawMany(); - - return records.map(res => { - return { - label: new Date(parseInt(res.homeGraph_label)).toISOString(), - data: res.homeGraph_data - } - }); - } -} \ No newline at end of file diff --git a/src/ORM/share-accounting/share-accounting.module.ts b/src/ORM/share-accounting/share-accounting.module.ts new file mode 100644 index 0000000..f2d688d --- /dev/null +++ b/src/ORM/share-accounting/share-accounting.module.ts @@ -0,0 +1,13 @@ +import { Global, Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { AcceptedShareEntity } from '../accepted-share/accepted-share.entity'; +import { ShareAccountingService } from './share-accounting.service'; + +@Global() +@Module({ + imports: [TypeOrmModule.forFeature([AcceptedShareEntity])], + providers: [ShareAccountingService], + exports: [TypeOrmModule, ShareAccountingService], +}) +export class ShareAccountingModule { } diff --git a/src/ORM/share-accounting/share-accounting.service.spec.ts b/src/ORM/share-accounting/share-accounting.service.spec.ts new file mode 100644 index 0000000..f101491 --- /dev/null +++ b/src/ORM/share-accounting/share-accounting.service.spec.ts @@ -0,0 +1,233 @@ +import { ShareAccountingService } from './share-accounting.service'; + +describe('ShareAccountingService', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('should normalize and save accepted share records', async () => { + const repository = { + create: jest.fn(value => value), + insert: jest.fn().mockResolvedValue({}), + }; + process.env.SHARE_ACCOUNTING_BATCH_SIZE = '1'; + const service = new ShareAccountingService(repository as any); + const acceptedAt = new Date('2026-06-07T12:00:00Z'); + + await expect(service.recordAcceptedShare({ + protocol: 'sv1', + acceptedAt, + address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', + clientName: 'worker', + sessionId: '57a6f098', + clientId: '6df7beea-921a-46cb-b7dc-e8075f916232', + jobId: '1', + jobTemplateId: '2', + blockHeight: 900000, + creditedDifficulty: 1024, + submissionDifficulty: 2048, + networkDifficulty: 100000, + nonce: 123, + ntime: 456, + version: 536870912, + extraNonce2: 'c708000000000000', + isBlockCandidate: false, + blockSubmissionResult: null, + })).resolves.toEqual(expect.objectContaining({ + protocol: 'sv1', + creditedDifficulty: 1024, + })); + + expect(repository.create).toHaveBeenCalledWith(expect.objectContaining({ + acceptedAt, + nonce: '123', + ntime: '456', + version: '536870912', + blockSubmissionResult: null, + })); + expect(repository.insert).toHaveBeenCalledWith([expect.objectContaining({ + protocol: 'sv1', + creditedDifficulty: 1024, + })]); + }); + + it('should batch accepted share inserts until the batch size is reached', async () => { + process.env.SHARE_ACCOUNTING_BATCH_SIZE = '2'; + process.env.SHARE_ACCOUNTING_FLUSH_INTERVAL_MS = '1000'; + + const repository = { + create: jest.fn(value => value), + insert: jest.fn().mockResolvedValue({}), + }; + const service = new ShareAccountingService(repository as any); + + const first = service.recordAcceptedShare(buildRecord('1')); + expect(repository.insert).not.toHaveBeenCalled(); + + const second = service.recordAcceptedShare(buildRecord('2')); + await expect(Promise.all([first, second])).resolves.toHaveLength(2); + + expect(repository.insert).toHaveBeenCalledTimes(1); + expect(repository.insert).toHaveBeenCalledWith([ + expect.objectContaining({ jobId: '1' }), + expect.objectContaining({ jobId: '2' }), + ]); + }); + + it('should flush partial batches on the flush interval', async () => { + jest.useFakeTimers(); + process.env.SHARE_ACCOUNTING_BATCH_SIZE = '10'; + process.env.SHARE_ACCOUNTING_FLUSH_INTERVAL_MS = '25'; + + const repository = { + create: jest.fn(value => value), + insert: jest.fn().mockResolvedValue({}), + }; + const service = new ShareAccountingService(repository as any); + + const pending = service.recordAcceptedShare(buildRecord('interval')); + expect(repository.insert).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(25); + await expect(pending).resolves.toEqual(expect.objectContaining({ jobId: 'interval' })); + expect(repository.insert).toHaveBeenCalledTimes(1); + }); + + it('should reject accepted shares when the accounting queue is full', async () => { + process.env.SHARE_ACCOUNTING_BATCH_SIZE = '10'; + process.env.SHARE_ACCOUNTING_FLUSH_INTERVAL_MS = '1000'; + process.env.SHARE_ACCOUNTING_MAX_QUEUE_SIZE = '1'; + + const repository = { + create: jest.fn(value => value), + insert: jest.fn().mockResolvedValue({}), + }; + const service = new ShareAccountingService(repository as any); + + const queued = service.recordAcceptedShare(buildRecord('queued')); + + await expect(service.recordAcceptedShare(buildRecord('overflow'))) + .rejects + .toThrow('Share accounting queue is full'); + await service.flushPendingShares(); + await expect(queued).resolves.toEqual(expect.objectContaining({ jobId: 'queued' })); + }); + + it('should return numeric accounting summaries with protocol breakdown', async () => { + const repository = { + query: jest.fn() + .mockResolvedValueOnce([{ + totalAcceptedShares: '3', + totalCreditedDifficulty: '96', + acceptedSharesLast10Minutes: '2', + creditedDifficultyLast10Minutes: '64', + acceptedSharesLastHour: '3', + creditedDifficultyLastHour: '96', + acceptedSharesLastDay: '3', + creditedDifficultyLastDay: '96', + hashRateLast10Minutes: '458129844.9', + hashRateLastHour: '114532461.2', + bestSubmissionDifficulty: '2048', + blockCandidateCount: '1', + latestShareAt: new Date('2026-06-07T12:10:00Z'), + }]) + .mockResolvedValueOnce([{ + protocol: 'sv1', + acceptedShares: '3', + creditedDifficulty: '96', + }]), + }; + const service = new ShareAccountingService(repository as any); + + await expect(service.getAddressSummary('bc1qtest')).resolves.toEqual({ + totalAcceptedShares: 3, + totalCreditedDifficulty: 96, + acceptedSharesLast10Minutes: 2, + creditedDifficultyLast10Minutes: 64, + acceptedSharesLastHour: 3, + creditedDifficultyLastHour: 96, + acceptedSharesLastDay: 3, + creditedDifficultyLastDay: 96, + hashRateLast10Minutes: 458129844.9, + hashRateLastHour: 114532461.2, + bestSubmissionDifficulty: 2048, + blockCandidateCount: 1, + latestShareAt: '2026-06-07T12:10:00.000Z', + protocolBreakdown: [{ + protocol: 'sv1', + acceptedShares: 3, + creditedDifficulty: 96, + }], + }); + expect(repository.query).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('"address" = $1'), + ['bc1qtest'], + ); + expect(repository.query).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('GROUP BY "protocol"'), + ['bc1qtest'], + ); + }); + + it('should cache accounting summaries briefly to protect hot dashboard endpoints', async () => { + process.env.SHARE_ACCOUNTING_SUMMARY_CACHE_MS = '1000'; + const repository = { + query: jest.fn() + .mockResolvedValueOnce([{ + totalAcceptedShares: '1', + totalCreditedDifficulty: '32', + acceptedSharesLast10Minutes: '1', + creditedDifficultyLast10Minutes: '32', + acceptedSharesLastHour: '1', + creditedDifficultyLastHour: '32', + acceptedSharesLastDay: '1', + creditedDifficultyLastDay: '32', + hashRateLast10Minutes: '1', + hashRateLastHour: '1', + bestSubmissionDifficulty: '32', + blockCandidateCount: '0', + latestShareAt: null, + }]) + .mockResolvedValueOnce([{ protocol: 'sv1', acceptedShares: '1', creditedDifficulty: '32' }]), + }; + const service = new ShareAccountingService(repository as any); + + await service.getPoolSummary(); + await service.getPoolSummary(); + + expect(repository.query).toHaveBeenCalledTimes(2); + }); +}); + +function buildRecord(jobId: string) { + return { + protocol: 'sv1' as const, + acceptedAt: new Date('2026-06-07T12:00:00Z'), + address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', + clientName: 'worker', + sessionId: '57a6f098', + clientId: '6df7beea-921a-46cb-b7dc-e8075f916232', + jobId, + jobTemplateId: 'template', + blockHeight: 900000, + creditedDifficulty: 1024, + submissionDifficulty: 2048, + networkDifficulty: 100000, + nonce: 123, + ntime: 456, + version: 536870912, + extraNonce2: 'c708000000000000', + isBlockCandidate: false, + blockSubmissionResult: null, + }; +} diff --git a/src/ORM/share-accounting/share-accounting.service.ts b/src/ORM/share-accounting/share-accounting.service.ts new file mode 100644 index 0000000..c8aa649 --- /dev/null +++ b/src/ORM/share-accounting/share-accounting.service.ts @@ -0,0 +1,374 @@ +import { Injectable, OnModuleDestroy } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { AcceptedShareEntity } from '../accepted-share/accepted-share.entity'; + +export interface AcceptedShareRecord { + protocol: 'sv1' | 'sv2'; + acceptedAt?: Date; + address: string; + clientName: string; + sessionId: string; + clientId: string; + jobId: string; + jobTemplateId: string; + blockHeight: number; + creditedDifficulty: number; + submissionDifficulty: number; + networkDifficulty: number; + nonce: string | number; + ntime: string | number; + version: string | number; + extraNonce2: string; + isBlockCandidate: boolean; + blockSubmissionResult?: string | null; +} + +export interface ShareAccountingSummary { + totalAcceptedShares: number; + totalCreditedDifficulty: number; + acceptedSharesLast10Minutes: number; + creditedDifficultyLast10Minutes: number; + acceptedSharesLastHour: number; + creditedDifficultyLastHour: number; + acceptedSharesLastDay: number; + creditedDifficultyLastDay: number; + hashRateLast10Minutes: number; + hashRateLastHour: number; + bestSubmissionDifficulty: number; + blockCandidateCount: number; + latestShareAt: string | null; + protocolBreakdown: { + protocol: string; + acceptedShares: number; + creditedDifficulty: number; + }[]; +} + +export interface SessionShareSummary { + clientId: string; + latestShareAt: string | null; + hashRateLast10Minutes: number; + bestSubmissionDifficulty: number; +} + +interface AccountingFilter { + address?: string; + clientName?: string; + clientId?: string; +} + +const HASHES_PER_DIFFICULTY = 4294967296; +const DEFAULT_BATCH_SIZE = 500; +const DEFAULT_FLUSH_INTERVAL_MS = 25; +const DEFAULT_MAX_QUEUE_SIZE = 50000; +const DEFAULT_SUMMARY_CACHE_MS = 2500; +const DEFAULT_SUMMARY_CACHE_MAX = 10000; + +@Injectable() +export class ShareAccountingService implements OnModuleDestroy { + private pendingShares: PendingShare[] = []; + private flushTimer: NodeJS.Timeout | null = null; + private activeFlush: Promise | null = null; + private summaryCache = new Map(); + private readonly batchSize = this.readPositiveInt('SHARE_ACCOUNTING_BATCH_SIZE', DEFAULT_BATCH_SIZE); + private readonly flushIntervalMs = this.readPositiveInt('SHARE_ACCOUNTING_FLUSH_INTERVAL_MS', DEFAULT_FLUSH_INTERVAL_MS); + private readonly maxQueueSize = this.readPositiveInt('SHARE_ACCOUNTING_MAX_QUEUE_SIZE', DEFAULT_MAX_QUEUE_SIZE); + private readonly summaryCacheMs = this.readNonNegativeInt('SHARE_ACCOUNTING_SUMMARY_CACHE_MS', DEFAULT_SUMMARY_CACHE_MS); + private readonly summaryCacheMax = this.readPositiveInt('SHARE_ACCOUNTING_SUMMARY_CACHE_MAX', DEFAULT_SUMMARY_CACHE_MAX); + + constructor( + @InjectRepository(AcceptedShareEntity) + private readonly acceptedShareRepository: Repository, + ) { } + + public async recordAcceptedShare(record: AcceptedShareRecord): Promise { + const acceptedShare = this.acceptedShareRepository.create({ + ...record, + acceptedAt: record.acceptedAt ?? new Date(), + nonce: record.nonce.toString(), + ntime: record.ntime.toString(), + version: record.version.toString(), + blockSubmissionResult: record.blockSubmissionResult == null + ? null + : record.blockSubmissionResult.toString(), + }); + + if (this.pendingShares.length >= this.maxQueueSize) { + throw new Error(`Share accounting queue is full (${this.maxQueueSize})`); + } + + return await new Promise((resolve, reject) => { + this.pendingShares.push({ entity: acceptedShare, resolve, reject }); + + if (this.pendingShares.length >= this.batchSize) { + this.startFlush(); + return; + } + + this.scheduleFlush(); + }); + } + + public async flushPendingShares(): Promise { + while (this.pendingShares.length > 0 || this.activeFlush != null) { + if (this.activeFlush != null) { + await this.activeFlush; + continue; + } + + this.startFlush(); + } + } + + public async onModuleDestroy(): Promise { + await this.flushPendingShares(); + } + + public async getPoolSummary(): Promise { + return this.getSummary({}); + } + + public async getAddressSummary(address: string): Promise { + return this.getSummary({ address }); + } + + public async getWorkerGroupSummary(address: string, clientName: string): Promise { + return this.getSummary({ address, clientName }); + } + + public async getSessionSummary(clientId: string): Promise { + return this.getSummary({ clientId }); + } + + public async getSessionSummaries(clientIds: string[]): Promise> { + const uniqueClientIds = [...new Set(clientIds.filter(clientId => clientId != null))]; + const summaries = new Map(); + if (uniqueClientIds.length === 0) { + return summaries; + } + + const rows = await this.acceptedShareRepository.query(` + SELECT + "clientId", + MAX("acceptedAt") AS "latestShareAt", + COALESCE((SUM("creditedDifficulty") FILTER (WHERE "acceptedAt" > NOW() - INTERVAL '10 minutes') * ${HASHES_PER_DIFFICULTY}) / 600, 0)::float AS "hashRateLast10Minutes", + COALESCE(MAX("submissionDifficulty"), 0)::float AS "bestSubmissionDifficulty" + FROM "accepted_share_entity" + WHERE "clientId" = ANY($1::uuid[]) + GROUP BY "clientId" + `, [uniqueClientIds]); + + rows.forEach(row => { + summaries.set(row.clientId, { + clientId: row.clientId, + latestShareAt: row.latestShareAt == null + ? null + : new Date(row.latestShareAt).toISOString(), + hashRateLast10Minutes: this.toNumber(row.hashRateLast10Minutes), + bestSubmissionDifficulty: this.toNumber(row.bestSubmissionDifficulty), + }); + }); + + return summaries; + } + + private async getSummary(filter: AccountingFilter): Promise { + const cacheKey = this.getSummaryCacheKey(filter); + const cached = this.summaryCache.get(cacheKey); + const now = Date.now(); + + if (cached != null && cached.expiresAt > now) { + return cached.value; + } + + const value = this.loadSummary(filter).catch(error => { + this.summaryCache.delete(cacheKey); + throw error; + }); + + if (this.summaryCacheMs > 0) { + this.summaryCache.set(cacheKey, { + expiresAt: now + this.summaryCacheMs, + value, + }); + this.trimSummaryCache(); + } + + return value; + } + + private async loadSummary(filter: AccountingFilter): Promise { + const { whereSql, params } = this.buildWhereClause(filter); + const [summary] = await this.acceptedShareRepository.query(` + SELECT + COUNT(*)::int AS "totalAcceptedShares", + COALESCE(SUM("creditedDifficulty"), 0)::float AS "totalCreditedDifficulty", + COUNT(*) FILTER (WHERE "acceptedAt" > NOW() - INTERVAL '10 minutes')::int AS "acceptedSharesLast10Minutes", + COALESCE(SUM("creditedDifficulty") FILTER (WHERE "acceptedAt" > NOW() - INTERVAL '10 minutes'), 0)::float AS "creditedDifficultyLast10Minutes", + COUNT(*) FILTER (WHERE "acceptedAt" > NOW() - INTERVAL '1 hour')::int AS "acceptedSharesLastHour", + COALESCE(SUM("creditedDifficulty") FILTER (WHERE "acceptedAt" > NOW() - INTERVAL '1 hour'), 0)::float AS "creditedDifficultyLastHour", + COUNT(*) FILTER (WHERE "acceptedAt" > NOW() - INTERVAL '1 day')::int AS "acceptedSharesLastDay", + COALESCE(SUM("creditedDifficulty") FILTER (WHERE "acceptedAt" > NOW() - INTERVAL '1 day'), 0)::float AS "creditedDifficultyLastDay", + COALESCE((SUM("creditedDifficulty") FILTER (WHERE "acceptedAt" > NOW() - INTERVAL '10 minutes') * ${HASHES_PER_DIFFICULTY}) / 600, 0)::float AS "hashRateLast10Minutes", + COALESCE((SUM("creditedDifficulty") FILTER (WHERE "acceptedAt" > NOW() - INTERVAL '1 hour') * ${HASHES_PER_DIFFICULTY}) / 3600, 0)::float AS "hashRateLastHour", + COALESCE(MAX("submissionDifficulty"), 0)::float AS "bestSubmissionDifficulty", + COUNT(*) FILTER (WHERE "isBlockCandidate" = TRUE)::int AS "blockCandidateCount", + MAX("acceptedAt") AS "latestShareAt" + FROM "accepted_share_entity" + ${whereSql} + `, params); + + const protocolRows = await this.acceptedShareRepository.query(` + SELECT + "protocol", + COUNT(*)::int AS "acceptedShares", + COALESCE(SUM("creditedDifficulty"), 0)::float AS "creditedDifficulty" + FROM "accepted_share_entity" + ${whereSql} + GROUP BY "protocol" + ORDER BY "protocol" + `, params); + + return { + totalAcceptedShares: this.toNumber(summary?.totalAcceptedShares), + totalCreditedDifficulty: this.toNumber(summary?.totalCreditedDifficulty), + acceptedSharesLast10Minutes: this.toNumber(summary?.acceptedSharesLast10Minutes), + creditedDifficultyLast10Minutes: this.toNumber(summary?.creditedDifficultyLast10Minutes), + acceptedSharesLastHour: this.toNumber(summary?.acceptedSharesLastHour), + creditedDifficultyLastHour: this.toNumber(summary?.creditedDifficultyLastHour), + acceptedSharesLastDay: this.toNumber(summary?.acceptedSharesLastDay), + creditedDifficultyLastDay: this.toNumber(summary?.creditedDifficultyLastDay), + hashRateLast10Minutes: this.toNumber(summary?.hashRateLast10Minutes), + hashRateLastHour: this.toNumber(summary?.hashRateLastHour), + bestSubmissionDifficulty: this.toNumber(summary?.bestSubmissionDifficulty), + blockCandidateCount: this.toNumber(summary?.blockCandidateCount), + latestShareAt: summary?.latestShareAt == null + ? null + : new Date(summary.latestShareAt).toISOString(), + protocolBreakdown: protocolRows.map(row => ({ + protocol: row.protocol, + acceptedShares: this.toNumber(row.acceptedShares), + creditedDifficulty: this.toNumber(row.creditedDifficulty), + })), + }; + } + + private scheduleFlush(): void { + if (this.flushTimer != null || this.activeFlush != null) { + return; + } + + this.flushTimer = setTimeout(() => { + this.flushTimer = null; + this.startFlush(); + }, this.flushIntervalMs); + this.flushTimer.unref?.(); + } + + private startFlush(): void { + if (this.activeFlush != null) { + return; + } + + if (this.flushTimer != null) { + clearTimeout(this.flushTimer); + this.flushTimer = null; + } + + const batch = this.pendingShares.splice(0, this.batchSize); + if (batch.length === 0) { + return; + } + + this.activeFlush = this.flushBatch(batch).finally(() => { + this.activeFlush = null; + + if (this.pendingShares.length >= this.batchSize) { + this.startFlush(); + return; + } + + if (this.pendingShares.length > 0) { + this.scheduleFlush(); + } + }); + } + + private async flushBatch(batch: PendingShare[]): Promise { + try { + await this.acceptedShareRepository.insert(batch.map(item => item.entity)); + batch.forEach(item => item.resolve(item.entity)); + } catch (error) { + batch.forEach(item => item.reject(error)); + } + } + + private buildWhereClause(filter: AccountingFilter): { whereSql: string; params: string[] } { + const where: string[] = []; + const params: string[] = []; + + if (filter.address != null) { + params.push(filter.address); + where.push(`"address" = $${params.length}`); + } + if (filter.clientName != null) { + params.push(filter.clientName); + where.push(`"clientName" = $${params.length}`); + } + if (filter.clientId != null) { + params.push(filter.clientId); + where.push(`"clientId" = $${params.length}`); + } + + return { + whereSql: where.length > 0 ? `WHERE ${where.join(' AND ')}` : '', + params, + }; + } + + private toNumber(value: unknown): number { + const parsed = Number(value ?? 0); + return Number.isFinite(parsed) ? parsed : 0; + } + + private getSummaryCacheKey(filter: AccountingFilter): string { + return JSON.stringify({ + address: filter.address ?? null, + clientName: filter.clientName ?? null, + clientId: filter.clientId ?? null, + }); + } + + private trimSummaryCache(): void { + while (this.summaryCache.size > this.summaryCacheMax) { + const firstKey = this.summaryCache.keys().next().value; + if (firstKey == null) { + return; + } + this.summaryCache.delete(firstKey); + } + } + + private readPositiveInt(name: string, defaultValue: number): number { + const value = Number(process.env[name]); + return Number.isInteger(value) && value > 0 ? value : defaultValue; + } + + private readNonNegativeInt(name: string, defaultValue: number): number { + const value = Number(process.env[name]); + return Number.isInteger(value) && value >= 0 ? value : defaultValue; + } +} + +interface PendingShare { + entity: AcceptedShareEntity; + resolve: (entity: AcceptedShareEntity) => void; + reject: (error: unknown) => void; +} + +interface SummaryCacheEntry { + expiresAt: number; + value: Promise; +} diff --git a/src/app.controller.ts b/src/app.controller.ts index 8e0d215..71498e3 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -8,10 +8,10 @@ import { AddressSettingsService } from './ORM/address-settings/address-settings. import { BlocksService } from './ORM/blocks/blocks.service'; import { ClientStatisticsService } from './ORM/client-statistics/client-statistics.service'; import { ClientService } from './ORM/client/client.service'; -import { HomeGraphService } from './ORM/home-graph/home-graph.service'; import { BitcoinRpcService } from './services/bitcoin-rpc.service'; import { UserAgentReportView } from './ORM/_views/user-agent-report/user-agent-report.view'; import { StratumV2Service } from './services/stratum-v2.service'; +import { ShareAccountingService } from './ORM/share-accounting/share-accounting.service'; @Controller() export class AppController { @@ -24,10 +24,10 @@ export class AppController { private readonly clientStatisticsService: ClientStatisticsService, private readonly blocksService: BlocksService, private readonly bitcoinRpcService: BitcoinRpcService, - private readonly homeGraphService: HomeGraphService, private readonly addressSettingsService: AddressSettingsService, private readonly userAgentReportService: UserAgentReportService, - private readonly stratumV2Service: StratumV2Service + private readonly stratumV2Service: StratumV2Service, + private readonly shareAccountingService: ShareAccountingService ) { } @Get('info') @@ -69,12 +69,15 @@ export class AppController { return pre; }, []); - userAgents.push({ userAgent: 'Other', count: other.count.toString(), bestDifficulty: other.bestDifficulty, totalHashRate: other.totalHashRate.toString() }) + if (other.count > 0) { + userAgents.push({ userAgent: 'Other', count: other.count.toString(), bestDifficulty: other.bestDifficulty, totalHashRate: other.totalHashRate.toString() }) + } const data = { blockData, userAgents, highScores, + accounting: await this.shareAccountingService.getPoolSummary(), sv2: { poolAuthorityPublicKey: poolAuthority.publicKey, authorityKeyConfigured: poolAuthority.configured @@ -82,13 +85,30 @@ export class AppController { uptime: this.uptime }; - //5 min - await this.cacheManager.set(CACHE_KEY, data, 5 * 60 * 1000); + // Keep online miner counts responsive after reconnect cleanup. + await this.cacheManager.set(CACHE_KEY, data, 15 * 1000); return data; } + @Get('info/accounting') + public async infoAccounting() { + const CACHE_KEY = 'SITE_ACCOUNTING'; + const cachedResult = await this.cacheManager.get(CACHE_KEY); + + if (cachedResult != null) { + return cachedResult; + } + + const data = await this.shareAccountingService.getPoolSummary(); + + //15 sec + await this.cacheManager.set(CACHE_KEY, data, 15 * 1000); + + return data; + } + @Get('pool') public async pool() { @@ -114,8 +134,8 @@ export class AppController { fee: 0 } - //5 min - await this.cacheManager.set(CACHE_KEY, data, 5 * 60 * 1000); + // Keep online miner counts responsive after reconnect cleanup. + await this.cacheManager.set(CACHE_KEY, data, 15 * 1000); return data; } @@ -136,7 +156,7 @@ export class AppController { return cachedResult; } - const chartData = await this.homeGraphService.getChartDataForSite(); + const chartData = await this.clientStatisticsService.getChartDataForSite(); //10 min await this.cacheManager.set(CACHE_KEY, chartData, 10 * 60 * 1000); diff --git a/src/app.module.ts b/src/app.module.ts index 1fed5b1..50f6e29 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -9,29 +9,22 @@ import { AppController } from './app.controller'; import { AddressController } from './controllers/address/address.controller'; import { ClientController } from './controllers/client/client.controller'; import { BitcoinAddressValidator } from './models/validators/bitcoin-address.validator'; -import { UniqueNonceIndex } from './ORM/_migrations/UniqueNonceIndex'; import { UserAgentReportModule } from './ORM/_views/user-agent-report/user-agent-report.module'; -import { UserAgentReportView } from './ORM/_views/user-agent-report/user-agent-report.view'; -import { AddressSettingsEntity } from './ORM/address-settings/address-settings.entity'; import { AddressSettingsModule } from './ORM/address-settings/address-settings.module'; -import { BlocksEntity } from './ORM/blocks/blocks.entity'; import { BlocksModule } from './ORM/blocks/blocks.module'; -import { ClientStatisticsEntity } from './ORM/client-statistics/client-statistics.entity'; import { ClientStatisticsModule } from './ORM/client-statistics/client-statistics.module'; -import { ClientEntity } from './ORM/client/client.entity'; import { ClientModule } from './ORM/client/client.module'; -import { HomeGraphEntity } from './ORM/home-graph/home-graph.entity'; -import { HomeGraphModule } from './ORM/home-graph/home-graph.module'; -import { RpcBlockEntity } from './ORM/rpc-block/rpc-block.entity'; import { RpcBlocksModule } from './ORM/rpc-block/rpc-block.module'; -import { TelegramSubscriptionsEntity } from './ORM/telegram-subscriptions/telegram-subscriptions.entity'; +import { ShareAccountingModule } from './ORM/share-accounting/share-accounting.module'; import { TelegramSubscriptionsModule } from './ORM/telegram-subscriptions/telegram-subscriptions.module'; +import { createDatabaseOptions } from './database.config'; import { AppService } from './services/app.service'; import { BitcoinRpcService } from './services/bitcoin-rpc.service'; import { BraiinsService } from './services/braiins.service'; import { BTCPayService } from './services/btc-pay.service'; import { DiscordService } from './services/discord.service'; import { NotificationService } from './services/notification.service'; +import { RedisMessagingModule } from './services/redis-messaging.module'; import { StratumV1JobsService } from './services/stratum-v1-jobs.service'; import { StratumV1Service } from './services/stratum-v1.service'; import { StratumV2Service } from './services/stratum-v2.service'; @@ -45,8 +38,8 @@ const ORMModules = [ TelegramSubscriptionsModule, BlocksModule, RpcBlocksModule, - HomeGraphModule, - UserAgentReportModule + UserAgentReportModule, + ShareAccountingModule ] @Module({ @@ -54,30 +47,16 @@ const ORMModules = [ ConfigModule.forRoot(), TypeOrmModule.forRootAsync({ useFactory: (configService: ConfigService) => { - return { - type: 'postgres', - host: configService.get('DB_HOST'), - port: parseInt(configService.get('DB_PORT')), - username: configService.get('DB_USERNAME'), - password: configService.get('DB_PASSWORD'), - database: configService.get('DB_DATABASE'), - entities: [ - ClientEntity, - AddressSettingsEntity, - BlocksEntity, - ClientStatisticsEntity, - RpcBlockEntity, - TelegramSubscriptionsEntity, - HomeGraphEntity, - UserAgentReportView - ], - synchronize: configService.get('PRODUCTION') != 'true', - logging: false, - poolSize: 10, - migrations: [ - UniqueNonceIndex - ] - } + return createDatabaseOptions({ + ...process.env, + DB_HOST: configService.get('DB_HOST'), + DB_PORT: configService.get('DB_PORT'), + DB_USERNAME: configService.get('DB_USERNAME'), + DB_PASSWORD: configService.get('DB_PASSWORD'), + DB_DATABASE: configService.get('DB_DATABASE'), + DB_LOGGING: configService.get('DB_LOGGING'), + DB_POOL_SIZE: configService.get('DB_POOL_SIZE'), + }); }, imports: [ConfigModule], inject: [ConfigService] @@ -85,6 +64,7 @@ const ORMModules = [ CacheModule.register(), ScheduleModule.forRoot(), HttpModule, + RedisMessagingModule, ...ORMModules ], controllers: [ diff --git a/src/controllers/client/client.controller.spec.ts b/src/controllers/client/client.controller.spec.ts index 080f448..18b00ff 100644 --- a/src/controllers/client/client.controller.spec.ts +++ b/src/controllers/client/client.controller.spec.ts @@ -1,32 +1,59 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { AddressSettingsModule } from '../../ORM/address-settings/address-settings.module'; -import { ClientStatisticsModule } from '../../ORM/client-statistics/client-statistics.module'; -import { ClientModule } from '../../ORM/client/client.module'; +import { AddressSettingsService } from '../../ORM/address-settings/address-settings.service'; +import { ClientStatisticsService } from '../../ORM/client-statistics/client-statistics.service'; +import { ClientService } from '../../ORM/client/client.service'; +import { ShareAccountingService } from '../../ORM/share-accounting/share-accounting.service'; +import { RedisMessagingService } from '../../services/redis-messaging.service'; import { ClientController } from './client.controller'; describe('ClientController', () => { let controller: ClientController; - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - imports: [ - TypeOrmModule.forRoot({ - type: 'better-sqlite3', - database: ':memory:', - synchronize: true, - autoLoadEntities: true, - cache: true, - logging: false - }), - AddressSettingsModule, - ClientModule, - ClientStatisticsModule - ], - controllers: [ClientController], + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ClientController], + providers: [ + { + provide: ClientService, + useValue: { + getByAddress: jest.fn(), + getByName: jest.fn(), + getBySessionId: jest.fn(), + }, + }, + { + provide: ClientStatisticsService, + useValue: { + getChartDataForAddress: jest.fn(), + getChartDataForGroup: jest.fn(), + getChartDataForSession: jest.fn(), + }, + }, + { + provide: AddressSettingsService, + useValue: { + getSettings: jest.fn(), + }, + }, + { + provide: ShareAccountingService, + useValue: { + getAddressSummary: jest.fn(), + getWorkerGroupSummary: jest.fn(), + getSessionSummary: jest.fn(), + getSessionSummaries: jest.fn().mockResolvedValue(new Map()), + }, + }, + { + provide: RedisMessagingService, + useValue: { + getClientPresenceByAddress: jest.fn().mockResolvedValue([]), + }, + }, + ], - }).compile(); + }).compile(); controller = module.get(ClientController); }); diff --git a/src/controllers/client/client.controller.ts b/src/controllers/client/client.controller.ts index b90574b..7a7535f 100644 --- a/src/controllers/client/client.controller.ts +++ b/src/controllers/client/client.controller.ts @@ -3,6 +3,8 @@ import { Controller, Get, NotFoundException, Param } from '@nestjs/common'; import { AddressSettingsService } from '../../ORM/address-settings/address-settings.service'; import { ClientStatisticsService } from '../../ORM/client-statistics/client-statistics.service'; import { ClientService } from '../../ORM/client/client.service'; +import { ShareAccountingService } from '../../ORM/share-accounting/share-accounting.service'; +import { RedisMessagingService } from '../../services/redis-messaging.service'; @Controller('client') @@ -11,29 +13,38 @@ export class ClientController { constructor( private readonly clientService: ClientService, private readonly clientStatisticsService: ClientStatisticsService, - private readonly addressSettingsService: AddressSettingsService + private readonly addressSettingsService: AddressSettingsService, + private readonly shareAccountingService: ShareAccountingService, + private readonly redisMessagingService: RedisMessagingService ) { } @Get(':address') async getClientInfo(@Param('address') address: string) { - const workers = await this.clientService.getByAddress(address); + const workers = await this.redisMessagingService.getClientPresenceByAddress(address); + const sessionSummaries = await this.shareAccountingService.getSessionSummaries(workers.map(worker => worker.clientId)); const addressSettings = await this.addressSettingsService.getSettings(address, false); return { bestDifficulty: addressSettings?.bestDifficulty, workersCount: workers.length, + accounting: await this.shareAccountingService.getAddressSummary(address), workers: await Promise.all( workers.map(async (worker) => { + const sessionSummary = sessionSummaries.get(worker.clientId); + const bestDifficulty = Math.max( + Number(worker.bestDifficulty ?? 0), + Number(sessionSummary?.bestSubmissionDifficulty ?? 0), + ); return { sessionId: worker.sessionId, name: worker.clientName, - bestDifficulty: parseFloat(worker.bestDifficulty as any).toFixed(2), - hashRate: worker.hashRate, + bestDifficulty: bestDifficulty.toFixed(2), + hashRate: sessionSummary?.hashRateLast10Minutes ?? worker.hashRate, startTime: worker.startTime, - lastSeen: worker.updatedAt + lastSeen: sessionSummary?.latestShareAt ?? worker.lastSeen }; }) ) @@ -49,7 +60,8 @@ export class ClientController { @Get(':address/:workerName') async getWorkerGroupInfo(@Param('address') address: string, @Param('workerName') workerName: string) { - const workers = await this.clientService.getByName(address, workerName); + const workers = (await this.redisMessagingService.getClientPresenceByAddress(address)) + .filter(worker => worker.clientName === workerName); const bestDifficulty = workers.reduce((pre, cur, idx, arr) => { if (cur.bestDifficulty > pre) { @@ -63,6 +75,7 @@ export class ClientController { name: workerName, bestDifficulty: Math.floor(bestDifficulty), + accounting: await this.shareAccountingService.getWorkerGroupSummary(address, workerName), chartData: chartData, } @@ -71,7 +84,17 @@ export class ClientController { @Get(':address/:workerName/:sessionId') async getWorkerInfo(@Param('address') address: string, @Param('workerName') workerName: string, @Param('sessionId') sessionId: string) { - const worker = await this.clientService.getBySessionId(address, workerName, sessionId); + const presenceWorker = (await this.redisMessagingService.getClientPresenceByAddress(address)) + .find(worker => worker.clientName === workerName && worker.sessionId === sessionId); + const worker = presenceWorker == null + ? await this.clientService.getBySessionId(address, workerName, sessionId) + : { + id: presenceWorker.clientId, + sessionId: presenceWorker.sessionId, + clientName: presenceWorker.clientName, + bestDifficulty: presenceWorker.bestDifficulty, + startTime: presenceWorker.startTime, + }; if (worker == null) { return new NotFoundException(); } @@ -81,6 +104,7 @@ export class ClientController { sessionId: worker.sessionId, name: worker.clientName, bestDifficulty: Math.floor(worker.bestDifficulty), + accounting: await this.shareAccountingService.getSessionSummary(worker.id), chartData: chartData, startTime: worker.startTime } diff --git a/src/data-source.ts b/src/data-source.ts new file mode 100644 index 0000000..1593d2c --- /dev/null +++ b/src/data-source.ts @@ -0,0 +1,7 @@ +import 'dotenv/config'; +import 'reflect-metadata'; +import { DataSource } from 'typeorm'; + +import { createDatabaseOptions } from './database.config'; + +export const AppDataSource = new DataSource(createDatabaseOptions(process.env)); diff --git a/src/database.config.ts b/src/database.config.ts new file mode 100644 index 0000000..0a424c3 --- /dev/null +++ b/src/database.config.ts @@ -0,0 +1,49 @@ +import { TypeOrmModuleOptions } from '@nestjs/typeorm'; +import { DataSourceOptions } from 'typeorm'; + +import { InitialTimescaleSchema1780859300000 } from './ORM/_migrations/InitialTimescaleSchema1780859300000'; +import { ActiveOnlyUserAgentReport1780860200000 } from './ORM/_migrations/ActiveOnlyUserAgentReport1780860200000'; +import { TimescaleOperationalHardening1780861200000 } from './ORM/_migrations/TimescaleOperationalHardening1780861200000'; +import { UserAgentReportView } from './ORM/_views/user-agent-report/user-agent-report.view'; +import { AcceptedShareEntity } from './ORM/accepted-share/accepted-share.entity'; +import { AddressSettingsEntity } from './ORM/address-settings/address-settings.entity'; +import { BlocksEntity } from './ORM/blocks/blocks.entity'; +import { ClientEntity } from './ORM/client/client.entity'; +import { RpcBlockEntity } from './ORM/rpc-block/rpc-block.entity'; +import { TelegramSubscriptionsEntity } from './ORM/telegram-subscriptions/telegram-subscriptions.entity'; + +export const databaseEntities = [ + ClientEntity, + AddressSettingsEntity, + BlocksEntity, + RpcBlockEntity, + TelegramSubscriptionsEntity, + UserAgentReportView, + AcceptedShareEntity, +]; + +export const databaseMigrations = [ + InitialTimescaleSchema1780859300000, + ActiveOnlyUserAgentReport1780860200000, + TimescaleOperationalHardening1780861200000, +]; + +export function createDatabaseOptions(env: NodeJS.ProcessEnv): TypeOrmModuleOptions & DataSourceOptions { + return { + type: 'postgres', + host: env.DB_HOST, + port: parseInt(env.DB_PORT ?? '5432', 10), + username: env.DB_USERNAME, + password: env.DB_PASSWORD, + database: env.DB_DATABASE, + entities: databaseEntities, + synchronize: false, + logging: env.DB_LOGGING === 'true', + poolSize: parseInt(env.DB_POOL_SIZE ?? '10', 10), + ssl: env.DB_SSL === 'true' + ? { rejectUnauthorized: env.DB_SSL_REJECT_UNAUTHORIZED !== 'false' } + : undefined, + migrations: databaseMigrations, + migrationsTransactionMode: 'none', + }; +} diff --git a/src/models/StratumV1Client.spec.ts b/src/models/StratumV1Client.spec.ts index 30632f5..6b8c86b 100644 --- a/src/models/StratumV1Client.spec.ts +++ b/src/models/StratumV1Client.spec.ts @@ -1,20 +1,10 @@ import { ConfigService } from '@nestjs/config'; -import { Test, TestingModule } from '@nestjs/testing'; -import { TypeOrmModule } from '@nestjs/typeorm'; import { Socket } from 'net'; import { BehaviorSubject } from 'rxjs'; -import { DataSource } from 'typeorm'; import { MockRecording1 } from '../../test/models/MockRecording1'; -import { AddressSettingsModule } from '../ORM/address-settings/address-settings.module'; import { AddressSettingsService } from '../ORM/address-settings/address-settings.service'; -import { BlocksEntity } from '../ORM/blocks/blocks.entity'; import { BlocksService } from '../ORM/blocks/blocks.service'; -import { ClientStatisticsEntity } from '../ORM/client-statistics/client-statistics.entity'; -import { ClientStatisticsModule } from '../ORM/client-statistics/client-statistics.module'; -import { ClientStatisticsService } from '../ORM/client-statistics/client-statistics.service'; -import { ClientEntity } from '../ORM/client/client.entity'; -import { ClientModule } from '../ORM/client/client.module'; import { ClientService } from '../ORM/client/client.service'; import { BitcoinRpcService as MockBitcoinRpcService } from '../services/bitcoin-rpc.service'; import { NotificationService } from '../services/notification.service'; @@ -45,10 +35,12 @@ describe('StratumV1Client', () => { let bitcoinRpcService: MockBitcoinRpcService; let clientService: ClientService; - let clientStatisticsService: ClientStatisticsService; let notificationService: NotificationService; let blocksService: BlocksService; let configService: ConfigService; + let addressSettings: AddressSettingsService; + let shareAccountingService: { recordAcceptedShare: jest.Mock }; + let redisMessagingService: { setClientPresence: jest.Mock; removeClientPresence: jest.Mock }; let client: StratumV1Client; @@ -60,46 +52,6 @@ describe('StratumV1Client', () => { let newBlockEmitter: BehaviorSubject = new BehaviorSubject(MockRecording1.BLOCK_TEMPLATE); - let moduleRef: TestingModule; - - beforeAll(async () => { - moduleRef = await Test.createTestingModule({ - imports: [ - TypeOrmModule.forRoot({ - type: 'better-sqlite3', - database: ':memory:', - synchronize: true, - autoLoadEntities: true, - cache: true, - logging: false, - entities: [ClientEntity, ClientStatisticsEntity, BlocksEntity] - }), - ClientModule, - ClientStatisticsModule, - AddressSettingsModule - ], - providers: [ - { - provide: ConfigService, - useValue: { - get: jest.fn((key: string) => { - switch (key) { - case 'DEV_FEE_ADDRESS': - return 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4'; - case 'NETWORK': - return 'testnet'; - } - return null; - }) - } - } - ], - }).compile(); - - - }) - - beforeEach(async () => { jest.useFakeTimers({ advanceTimers: true }) @@ -108,27 +60,38 @@ describe('StratumV1Client', () => { consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); - clientService = moduleRef.get(ClientService); + const clients = new Map(); + let nextClientId = 1; + clientService = { + insert: jest.fn(async (partialClient) => { + const clientEntity = { + id: `00000000-0000-4000-8000-${String(nextClientId++).padStart(12, '0')}`, + hashRate: 0, + updatedAt: new Date(), + deletedAt: null, + ...partialClient, + }; + clients.set(clientEntity.id, clientEntity); + return clientEntity; + }), + delete: jest.fn(async (id: string) => { + clients.delete(id); + }), + connectedClientCount: jest.fn(async () => clients.size), + updateBestDifficultyIfHigher: jest.fn().mockResolvedValue({ affected: 1 }), + } as any; - const dataSource = moduleRef.get(DataSource); - - await dataSource.getRepository(ClientStatisticsEntity).clear(); - await dataSource.getRepository(ClientEntity).clear(); - await dataSource.getRepository(BlocksEntity).clear(); - - - clientStatisticsService = moduleRef.get(ClientStatisticsService); - - configService = moduleRef.get(ConfigService); - (configService.get as jest.Mock).mockImplementation((key: string) => { - switch (key) { - case 'DEV_FEE_ADDRESS': - return 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4'; - case 'NETWORK': - return 'testnet'; - } - return null; - }); + configService = { + get: jest.fn((key: string) => { + switch (key) { + case 'DEV_FEE_ADDRESS': + return 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4'; + case 'NETWORK': + return 'testnet'; + } + return null; + }) + } as any; (StratumV1Client as any).blockedUserAgentLogState.clear(); (StratumV1Client as any).validationErrorLogState.clear(); @@ -154,13 +117,24 @@ describe('StratumV1Client', () => { socket.end = jest.fn(); jest.spyOn(socket, 'destroy').mockImplementation(() => socket); - const addressSettings = moduleRef.get(AddressSettingsService); + addressSettings = { + getSettings: jest.fn().mockResolvedValue(null), + updateBestDifficultyIfHigher: jest.fn().mockResolvedValue({ affected: 1 }), + resetBestDifficultyAndShares: jest.fn().mockResolvedValue(undefined), + } as any; notificationService = { notifySubscribersBlockFound: jest.fn().mockResolvedValue(undefined) } as any; blocksService = { save: jest.fn().mockResolvedValue(undefined) } as any; + shareAccountingService = { + recordAcceptedShare: jest.fn().mockResolvedValue(undefined), + }; + redisMessagingService = { + setClientPresence: jest.fn().mockResolvedValue(undefined), + removeClientPresence: jest.fn().mockResolvedValue(undefined), + }; client = new StratumV1Client( @@ -168,11 +142,12 @@ describe('StratumV1Client', () => { stratumV1JobsService, bitcoinRpcService, clientService, - clientStatisticsService, notificationService, blocksService, configService, - addressSettings + addressSettings, + shareAccountingService as any, + redisMessagingService as any ); client.extraNonceAndSessionId = MockRecording1.EXTRA_NONCE; @@ -263,11 +238,10 @@ describe('StratumV1Client', () => { stratumV1JobsService, bitcoinRpcService, clientService, - clientStatisticsService, notificationService, blocksService, configService, - moduleRef.get(AddressSettingsService) + addressSettings ); socketEmitter(Buffer.from(`{"id":1,"method":"mining.subscribe","params":["NMMiner/1.0"]}\n`)); @@ -369,6 +343,21 @@ describe('StratumV1Client', () => { await new Promise((r) => setTimeout(r, 1000)); expect((client as any).write).lastCalledWith(`{\"id\":5,\"error\":null,\"result\":true}\n`); + expect(shareAccountingService.recordAcceptedShare).toHaveBeenCalledWith(expect.objectContaining({ + protocol: 'sv1', + address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', + clientName: 'bitaxe3', + sessionId: MockRecording1.EXTRA_NONCE, + jobId: '1', + jobTemplateId: '1', + creditedDifficulty: 0, + isBlockCandidate: false, + })); + expect(redisMessagingService.setClientPresence).toHaveBeenCalledWith(expect.objectContaining({ + address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', + clientName: 'bitaxe3', + sessionId: MockRecording1.EXTRA_NONCE, + })); }); @@ -423,7 +412,6 @@ describe('StratumV1Client', () => { submissionDifficulty: 1024, submissionHash: 'share' }); - const addressSettings = moduleRef.get(AddressSettingsService); const getSettingsSpy = jest.spyOn(addressSettings, 'getSettings'); const updateIfHigherSpy = jest.spyOn(addressSettings as any, 'updateBestDifficultyIfHigher').mockResolvedValue({ affected: 1 }); const clientUpdateIfHigherSpy = jest.spyOn(clientService as any, 'updateBestDifficultyIfHigher').mockResolvedValue({ affected: 1 }); @@ -546,11 +534,10 @@ describe('StratumV1Client', () => { stratumV1JobsService, bitcoinRpcService, clientService, - clientStatisticsService, notificationService, blocksService, configService, - moduleRef.get(AddressSettingsService) + addressSettings ); jest.spyOn(secondClient as any, 'write').mockImplementation((data) => Promise.resolve(true)); jest.spyOn(secondClient as any, 'getRandomHexString').mockReturnValue(MockRecording1.EXTRA_NONCE); @@ -580,7 +567,6 @@ describe('StratumV1Client', () => { submissionDifficulty: Number.MAX_SAFE_INTEGER, submissionHash: 'block-share' }); - const addressSettings = moduleRef.get(AddressSettingsService); jest.spyOn(addressSettings, 'resetBestDifficultyAndShares').mockResolvedValue(undefined); emitMessage(MockRecording1.MINING_SUBSCRIBE); diff --git a/src/models/StratumV1Client.ts b/src/models/StratumV1Client.ts index 6b13ea7..2da937d 100644 --- a/src/models/StratumV1Client.ts +++ b/src/models/StratumV1Client.ts @@ -9,11 +9,12 @@ import { clearInterval } from 'timers'; import { AddressSettingsService } from '../ORM/address-settings/address-settings.service'; import { BlocksService } from '../ORM/blocks/blocks.service'; -import { ClientStatisticsService } from '../ORM/client-statistics/client-statistics.service'; import { ClientEntity } from '../ORM/client/client.entity'; import { ClientService } from '../ORM/client/client.service'; +import { ShareAccountingService } from '../ORM/share-accounting/share-accounting.service'; import { BitcoinRpcService } from '../services/bitcoin-rpc.service'; import { NotificationService } from '../services/notification.service'; +import { RedisMessagingService } from '../services/redis-messaging.service'; import { IJobTemplate, StratumV1JobsService } from '../services/stratum-v1-jobs.service'; import { eRequestMethod } from './enums/eRequestMethod'; import { eResponseMethod } from './enums/eResponseMethod'; @@ -67,11 +68,12 @@ export class StratumV1Client { private readonly stratumV1JobsService: StratumV1JobsService, private readonly bitcoinRpcService: BitcoinRpcService, private readonly clientService: ClientService, - private readonly clientStatisticsService: ClientStatisticsService, private readonly notificationService: NotificationService, private readonly blocksService: BlocksService, private readonly configService: ConfigService, - private readonly addressSettingsService: AddressSettingsService + private readonly addressSettingsService: AddressSettingsService, + private readonly shareAccountingService?: ShareAccountingService, + private readonly redisMessagingService?: RedisMessagingService ) { this.socket.on('data', (data: Buffer) => { @@ -100,6 +102,7 @@ export class StratumV1Client { public async destroy() { if (this.clientEntity?.id) { + await this.redisMessagingService?.removeClientPresence(this.clientEntity.id, this.clientEntity.address); await this.clientService.delete(this.clientEntity.id); } @@ -156,7 +159,7 @@ export class StratumV1Client { if (this.sessionStart == null) { this.sessionStart = new Date(); - this.statistics = new StratumV1ClientStatistics(this.clientStatisticsService); + this.statistics = new StratumV1ClientStatistics(); this.extraNonceAndSessionId = this.getRandomHexString(); //console.log(`New client ID: : ${this.extraNonceAndSessionId}, ${this.socket.remoteAddress}:${this.socket.remotePort}`); } @@ -422,7 +425,6 @@ export class StratumV1Client { // //50Th/s // this.noFee = false; // if (this.clientEntity) { - // this.hashRate = await this.clientStatisticsService.getHashRateForSession(this.clientEntity.id); // // 250Gh/s // if(this.hashRate < 250000000000){ // this.statistics.targetSubmitShareEveryNSeconds = 10; @@ -491,12 +493,35 @@ export class StratumV1Client { startTime: new Date(), bestDifficulty: 0 }); + await this.updateClientPresence(new Date()); })(); } await this.creatingEntity; } + private async updateClientPresence(lastSeen: Date): Promise { + if (this.clientEntity == null) { + return; + } + + try { + await this.redisMessagingService?.setClientPresence({ + clientId: this.clientEntity.id, + address: this.clientEntity.address, + clientName: this.clientEntity.clientName, + sessionId: this.clientEntity.sessionId, + userAgent: this.clientEntity.userAgent, + startTime: new Date(this.clientEntity.startTime).toISOString(), + lastSeen: lastSeen.toISOString(), + hashRate: this.statistics?.hashRate ?? 0, + bestDifficulty: Number(this.clientEntity.bestDifficulty ?? 0), + }); + } catch (error) { + console.error(`Failed to update SV1 client presence: ${error.message}`); + } + } + private async handleMiningSubmission(submission: MiningSubmitMessage) { const job = this.stratumV1JobsService.getJobById(submission.jobId); @@ -574,6 +599,7 @@ export class StratumV1Client { return false; } + let blockSubmissionResult: string = null; if (submissionDifficulty >= jobTemplate.blockData.networkDifficulty) { console.log('!!! BLOCK FOUND !!!'); const updatedJobBlock = job.copyAndUpdateBlock( @@ -585,7 +611,7 @@ export class StratumV1Client { timestamp ); const blockHex = updatedJobBlock.toHex(false); - const result = await this.bitcoinRpcService.SUBMIT_BLOCK(blockHex); + blockSubmissionResult = await this.bitcoinRpcService.SUBMIT_BLOCK(blockHex); await this.blocksService.save({ height: jobTemplate.blockData.height, minerAddress: this.clientAuthorization.address, @@ -594,21 +620,40 @@ export class StratumV1Client { blockData: blockHex }); - await this.notificationService.notifySubscribersBlockFound(this.clientAuthorization.address, jobTemplate.blockData.height, updatedJobBlock, result); + await this.notificationService.notifySubscribersBlockFound(this.clientAuthorization.address, jobTemplate.blockData.height, updatedJobBlock, blockSubmissionResult); //success - if (result == null) { + if (blockSubmissionResult == null) { await this.addressSettingsService.resetBestDifficultyAndShares(); } } await this.ensureClientEntity(); try { + await this.shareAccountingService?.recordAcceptedShare({ + protocol: 'sv1', + address: this.clientAuthorization.address, + clientName: this.clientAuthorization.worker, + sessionId: this.extraNonceAndSessionId, + clientId: this.clientEntity.id, + jobId: job.jobId, + jobTemplateId: job.jobTemplateId, + blockHeight: jobTemplate.blockData.height, + creditedDifficulty: this.sessionDifficulty, + submissionDifficulty, + networkDifficulty: jobTemplate.blockData.networkDifficulty, + nonce: submission.nonce, + ntime: submission.ntime, + version: Number.isFinite(versionMask) + ? (jobTemplate.block.version ^ versionMask).toString(16) + : jobTemplate.block.version.toString(16), + extraNonce2: submission.extraNonce2, + isBlockCandidate: submissionDifficulty >= jobTemplate.blockData.networkDifficulty, + blockSubmissionResult, + }); await this.statistics.addShares(this.clientEntity, this.sessionDifficulty); const now = new Date(); - // only update every minute - //if (this.clientEntity.updatedAt == null || now.getTime() - this.clientEntity.updatedAt.getTime() > 1000 * 60) { - this.clientService.heartbeatBulkAsync(this.clientEntity.id, this.statistics.hashRate, now); this.clientEntity.updatedAt = now; - //} + this.clientEntity.hashRate = this.statistics.hashRate; + await this.updateClientPresence(now); } catch (e) { console.log(e); @@ -618,6 +663,7 @@ export class StratumV1Client { await this.clientService.updateBestDifficultyIfHigher(this.clientEntity.id, submissionDifficulty); this.clientEntity.bestDifficulty = submissionDifficulty; await this.addressSettingsService.updateBestDifficultyIfHigher(this.clientAuthorization.address, submissionDifficulty, this.clientEntity.userAgent); + await this.updateClientPresence(new Date()); } diff --git a/src/models/StratumV1ClientStatistics.spec.ts b/src/models/StratumV1ClientStatistics.spec.ts index 33cf0ec..6668594 100644 --- a/src/models/StratumV1ClientStatistics.spec.ts +++ b/src/models/StratumV1ClientStatistics.spec.ts @@ -8,73 +8,31 @@ describe('StratumV1ClientStatistics', () => { sessionId: '57a6f098' } as any; - let clientStatisticsService: { - insert: jest.Mock, - updateBulkAsync: jest.Mock - }; let statistics: StratumV1ClientStatistics; beforeEach(() => { jest.useFakeTimers(); jest.setSystemTime(new Date('2026-05-06T12:00:00Z')); - clientStatisticsService = { - insert: jest.fn().mockResolvedValue(undefined), - updateBulkAsync: jest.fn().mockResolvedValue(undefined) - }; - statistics = new StratumV1ClientStatistics(clientStatisticsService as any); + statistics = new StratumV1ClientStatistics(); }); afterEach(() => { jest.useRealTimers(); }); - it('should insert the first share bucket', async () => { + it('should keep runtime hashrate at zero before enough time passes', async () => { await statistics.addShares(client, 64); - expect(clientStatisticsService.insert).toHaveBeenCalledWith({ - time: new Date('2026-05-06T12:00:00Z').getTime(), - clientId: client.id, - shares: 64, - acceptedCount: 1, - address: client.address, - clientName: client.clientName, - sessionId: client.sessionId - }); + expect(statistics.hashRate).toBe(0); }); - it('should update the current share bucket for additional shares', async () => { - await statistics.addShares(client, 64); - await statistics.addShares(client, 32); + it('should update runtime hashrate from accepted shares', async () => { + for (let i = 0; i < 3; i++) { + jest.setSystemTime(new Date(Date.parse('2026-05-06T12:00:00Z') + (i * 31000))); + await statistics.addShares(client, 64); + } - expect(clientStatisticsService.updateBulkAsync).toHaveBeenCalledWith({ - time: new Date('2026-05-06T12:00:00Z').getTime(), - clientId: client.id, - shares: 96, - acceptedCount: 2 - }); - }); - - it('should create a new share bucket when the time slot changes', async () => { - await statistics.addShares(client, 64); - jest.setSystemTime(new Date('2026-05-06T12:10:00Z')); - - await statistics.addShares(client, 32); - - expect(clientStatisticsService.updateBulkAsync).toHaveBeenCalledWith({ - time: new Date('2026-05-06T12:00:00Z').getTime(), - clientId: client.id, - shares: 64, - acceptedCount: 1 - }); - expect(clientStatisticsService.insert).toHaveBeenLastCalledWith({ - time: new Date('2026-05-06T12:10:00Z').getTime(), - clientId: client.id, - shares: 32, - acceptedCount: 1, - address: client.address, - clientName: client.clientName, - sessionId: client.sessionId - }); + expect(statistics.hashRate).toBeGreaterThan(0); }); it('should not suggest a difficulty change before enough time or shares have passed', () => { diff --git a/src/models/StratumV1ClientStatistics.ts b/src/models/StratumV1ClientStatistics.ts index aebe42d..86cee6b 100644 --- a/src/models/StratumV1ClientStatistics.ts +++ b/src/models/StratumV1ClientStatistics.ts @@ -1,4 +1,3 @@ -import { ClientStatisticsService } from '../ORM/client-statistics/client-statistics.service'; import { ClientEntity } from '../ORM/client/client.entity'; const CACHE_SIZE = 30; @@ -8,31 +7,16 @@ export class StratumV1ClientStatistics { public targetSubmitShareEveryNSeconds: number = 30; public hashRate = 0; - private shares: number = 0; - private acceptedCount: number = 0; - private submissionCacheStart: Date; private submissionCache: { time: Date, difficulty: number }[] = []; private submissionCacheDifficultySum = 0; - private currentTimeSlot: number = null; - - constructor( - private readonly clientStatisticsService: ClientStatisticsService, - ) { + constructor() { this.submissionCacheStart = new Date(); } - - - // We don't want to save them here because it can be DB intensive, instead do it every once in - // awhile with saveShares() - public async addShares(client: ClientEntity, targetDifficulty: number) { - - // 10 min - var coeff = 1000 * 60 * 10; + public async addShares(_client: ClientEntity, targetDifficulty: number) { var date = new Date(); - var timeSlot = new Date(Math.floor(date.getTime() / coeff) * coeff).getTime(); if (this.submissionCache.length > CACHE_SIZE) { this.submissionCacheDifficultySum -= this.submissionCache[0].difficulty; @@ -44,55 +28,6 @@ export class StratumV1ClientStatistics { }); this.submissionCacheDifficultySum += targetDifficulty; - if (this.currentTimeSlot == null) { - // First record, insert it - this.currentTimeSlot = timeSlot; - this.shares += targetDifficulty; - this.acceptedCount++; - await this.clientStatisticsService.insert({ - time: this.currentTimeSlot, - clientId: client.id, - shares: this.shares, - acceptedCount: this.acceptedCount, - address: client.address, - clientName: client.clientName, - sessionId: client.sessionId - }); - } else if (this.currentTimeSlot != timeSlot) { - // Transitioning to a new time slot, - // First update the old time slot with the latest data - this.clientStatisticsService.updateBulkAsync({ - time: this.currentTimeSlot, - clientId: client.id, - shares: this.shares, - acceptedCount: this.acceptedCount, - }); - // Set the new time slot and add incoming shares then insert it - this.currentTimeSlot = timeSlot; - this.shares = targetDifficulty; - this.acceptedCount = 1 - await this.clientStatisticsService.insert({ - time: this.currentTimeSlot, - clientId: client.id, - shares: this.shares, - acceptedCount: this.acceptedCount, - address: client.address, - clientName: client.clientName, - sessionId: client.sessionId - }); - } else { - // Accept the shares if none of the prior conditions are met, - // saving to memory for storing later - this.shares += targetDifficulty; - this.acceptedCount++; - this.clientStatisticsService.updateBulkAsync({ - time: this.currentTimeSlot, - clientId: client.id, - shares: this.shares, - acceptedCount: this.acceptedCount, - }); - } - const time = new Date().getTime() - this.submissionCache[0].time.getTime(); if(time > 60000 && this.submissionCache.length > 2) { this.hashRate = (this.submissionCacheDifficultySum * 4294967296) / (time / 1000); @@ -151,4 +86,4 @@ export class StratumV1ClientStatistics { return res; } -} \ No newline at end of file +} diff --git a/src/models/StratumV2Client.spec.ts b/src/models/StratumV2Client.spec.ts index 0efcd5e..37b2dbd 100644 --- a/src/models/StratumV2Client.spec.ts +++ b/src/models/StratumV2Client.spec.ts @@ -107,7 +107,51 @@ describe('StratumV2Client extended channels', () => { expect(error.errorCode).toBe('invalid-extranonce-size'); }); - async function createClient(): Promise<{ client: StratumV2Client; sentFrames: any[] }> { + it('records accepted SV2 shares before presence updates', async () => { + const { client, shareAccountingService, redisMessagingService, jobTemplate } = await createClient(); + (client as any).address = 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4'; + (client as any).workerName = 'worker'; + (client as any).sessionId = 'sv2-session'; + (client as any).userAgent = 'test/sv2'; + + await (client as any).recordAcceptedShare( + 2048, + 1024, + jobTemplate, + null, + { + jobId: '1', + nonce: 123, + ntime: parseInt(MockRecording1.TIME, 16), + version: jobTemplate.block.version, + extraNonce2: 'c708000000000000', + }, + ); + + expect(shareAccountingService.recordAcceptedShare).toHaveBeenCalledWith(expect.objectContaining({ + protocol: 'sv2', + address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', + clientName: 'worker', + sessionId: 'sv2-session', + jobId: '1', + creditedDifficulty: 1024, + submissionDifficulty: 2048, + nonce: 123, + extraNonce2: 'c708000000000000', + })); + const accountingCallOrder = shareAccountingService.recordAcceptedShare.mock.invocationCallOrder[0]; + const postAccountingPresenceUpdates = redisMessagingService.setClientPresence.mock.invocationCallOrder + .filter(callOrder => callOrder > accountingCallOrder); + expect(postAccountingPresenceUpdates.length).toBeGreaterThan(0); + }); + + async function createClient(): Promise<{ + client: StratumV2Client; + sentFrames: any[]; + shareAccountingService: { recordAcceptedShare: jest.Mock }; + redisMessagingService: { setClientPresence: jest.Mock; removeClientPresence: jest.Mock }; + jobTemplate: any; + }> { const blockTemplate$ = new BehaviorSubject(MockRecording1.BLOCK_TEMPLATE); const bitcoinRpcService = { newBlockTemplate$: blockTemplate$.asObservable(), @@ -115,7 +159,7 @@ describe('StratumV2Client extended channels', () => { SUBMIT_BLOCK: jest.fn().mockResolvedValue(null), }; const stratumV1JobsService = new StratumV1JobsService(bitcoinRpcService as any); - await firstValueFrom(stratumV1JobsService.newMiningJob$); + const jobTemplate = await firstValueFrom(stratumV1JobsService.newMiningJob$); const mockSocket: any = { destroyed: false, @@ -138,11 +182,24 @@ describe('StratumV2Client extended channels', () => { address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', clientName: 'worker', userAgent: 'test/sv2', + startTime: new Date(), bestDifficulty: 0, } as unknown as ClientEntity; let nextChannelId = 1; const sentFrames: any[] = []; + const clientService = { + insert: jest.fn().mockResolvedValue(clientEntity), + delete: jest.fn().mockResolvedValue(undefined), + updateBestDifficultyIfHigher: jest.fn().mockResolvedValue(undefined), + }; + const shareAccountingService = { + recordAcceptedShare: jest.fn().mockResolvedValue(undefined), + }; + const redisMessagingService = { + setClientPresence: jest.fn().mockResolvedValue(undefined), + removeClientPresence: jest.fn().mockResolvedValue(undefined), + }; const client = new StratumV2Client( socket, Buffer.alloc(0), @@ -167,16 +224,7 @@ describe('StratumV2Client extended channels', () => { } as any, stratumV1JobsService, bitcoinRpcService as any, - { - insert: jest.fn().mockResolvedValue(clientEntity), - delete: jest.fn().mockResolvedValue(undefined), - heartbeatBulkAsync: jest.fn(), - updateBestDifficultyIfHigher: jest.fn().mockResolvedValue(undefined), - } as any, - { - insert: jest.fn().mockResolvedValue(undefined), - updateBulkAsync: jest.fn(), - } as any, + clientService as any, { notifySubscribersBlockFound: jest.fn().mockResolvedValue(undefined) } as any, { save: jest.fn().mockResolvedValue(undefined) } as any, { @@ -195,12 +243,14 @@ describe('StratumV2Client extended channels', () => { resetBestDifficultyAndShares: jest.fn().mockResolvedValue(undefined), updateBestDifficultyIfHigher: jest.fn().mockResolvedValue(undefined), } as any, + shareAccountingService as any, + redisMessagingService as any, ); (client as any).sendFrame = jest.fn((msgType: number, payload: Buffer, extensionType = 0) => { sentFrames.push({ msgType, payload, extensionType }); return Promise.resolve(); }); - return { client, sentFrames }; + return { client, sentFrames, shareAccountingService, redisMessagingService, jobTemplate }; } }); diff --git a/src/models/StratumV2Client.ts b/src/models/StratumV2Client.ts index a29e07d..8c86f08 100644 --- a/src/models/StratumV2Client.ts +++ b/src/models/StratumV2Client.ts @@ -7,11 +7,12 @@ import { firstValueFrom, Subscription } from 'rxjs'; import { AddressSettingsService } from '../ORM/address-settings/address-settings.service'; import { BlocksService } from '../ORM/blocks/blocks.service'; -import { ClientStatisticsService } from '../ORM/client-statistics/client-statistics.service'; import { ClientEntity } from '../ORM/client/client.entity'; import { ClientService } from '../ORM/client/client.service'; +import { ShareAccountingService } from '../ORM/share-accounting/share-accounting.service'; import { BitcoinRpcService } from '../services/bitcoin-rpc.service'; import { NotificationService } from '../services/notification.service'; +import { RedisMessagingService } from '../services/redis-messaging.service'; import { StratumV2Service } from '../services/stratum-v2.service'; import { IJobTemplate, StratumV1JobsService } from '../services/stratum-v1-jobs.service'; import { patchCoinbasePrefixVarint } from '../utils/coinbase-prefix.utils'; @@ -128,18 +129,19 @@ export class StratumV2Client { private readonly stratumV1JobsService: StratumV1JobsService, private readonly bitcoinRpcService: BitcoinRpcService, private readonly clientService: ClientService, - private readonly clientStatisticsService: ClientStatisticsService, private readonly notificationService: NotificationService, private readonly blocksService: BlocksService, private readonly configService: ConfigService, private readonly addressSettingsService: AddressSettingsService, + private readonly shareAccountingService?: ShareAccountingService, + private readonly redisMessagingService?: RedisMessagingService, ) { this.firstChunkSummary = this.describeChunk(firstChunk); this.noiseSession = new Sv2NoiseSession(this.stratumV2Service.getNoiseConfig()); this.sessionDifficulty = this.getInitialDifficulty(); this.targetSharesPerMinute = this.getTargetSharesPerMinute(); this.difficultyCheckIntervalMs = this.getDifficultyCheckIntervalMs(); - this.statistics = new StratumV1ClientStatistics(this.clientStatisticsService); + this.statistics = new StratumV1ClientStatistics(); this.statistics.targetSubmitShareEveryNSeconds = 60 / this.targetSharesPerMinute; this.network = this.getNetwork(); @@ -171,6 +173,7 @@ export class StratumV2Client { } this.channels.clear(); if (this.clientEntity?.id != null) { + await this.redisMessagingService?.removeClientPresence(this.clientEntity.id, this.clientEntity.address); await this.clientService.delete(this.clientEntity.id); } } @@ -647,7 +650,13 @@ export class StratumV2Client { if (submissionDifficulty >= extendedJob.jobTemplate.blockData.networkDifficulty) { updatedJobBlock = this.reconstructExtendedBlock(extendedJob, submission, merkleRoot, channel.extranoncePrefix); } - await this.recordAcceptedShare(submissionDifficulty, jobDifficulty, extendedJob.jobTemplate, updatedJobBlock); + await this.recordAcceptedShare(submissionDifficulty, jobDifficulty, extendedJob.jobTemplate, updatedJobBlock, { + jobId: submission.jobId.toString(16), + nonce: submission.nonce, + ntime: submission.ntime, + version: submission.version, + extraNonce2: submission.extranonce.toString('hex'), + }); } private async handleAcceptedShare( @@ -671,7 +680,13 @@ export class StratumV2Client { ); } - await this.recordAcceptedShare(submissionDifficulty, jobDifficulty, jobTemplate, updatedJobBlock); + await this.recordAcceptedShare(submissionDifficulty, jobDifficulty, jobTemplate, updatedJobBlock, { + jobId: job.jobId, + nonce: submission.nonce, + ntime: submission.ntime, + version: submission.version, + extraNonce2: FIXED_STANDARD_EXTRANONCE2, + }); } private async recordAcceptedShare( @@ -679,13 +694,15 @@ export class StratumV2Client { jobDifficulty: number, jobTemplate: IJobTemplate, updatedJobBlock: bitcoinjs.Block | null, + share: { jobId: string; nonce: number; ntime: number; version: number; extraNonce2: string }, ): Promise { await this.ensureClientEntity(); + let blockSubmissionResult: string = null; if (updatedJobBlock != null) { console.log(`[SV2 ${this.sessionId}] BLOCK FOUND at height ${jobTemplate.blockData.height}`); const blockHex = updatedJobBlock.toHex(false); - const result = await this.bitcoinRpcService.SUBMIT_BLOCK(blockHex); + blockSubmissionResult = await this.bitcoinRpcService.SUBMIT_BLOCK(blockHex); await this.blocksService.save({ height: jobTemplate.blockData.height, minerAddress: this.address, @@ -697,17 +714,37 @@ export class StratumV2Client { this.address, jobTemplate.blockData.height, updatedJobBlock, - result, + blockSubmissionResult, ); - if (result == null) { + if (blockSubmissionResult == null) { await this.addressSettingsService.resetBestDifficultyAndShares(); } } + await this.shareAccountingService?.recordAcceptedShare({ + protocol: 'sv2', + address: this.address, + clientName: this.workerName, + sessionId: this.sessionId, + clientId: this.clientEntity.id, + jobId: share.jobId, + jobTemplateId: jobTemplate.blockData.id, + blockHeight: jobTemplate.blockData.height, + creditedDifficulty: jobDifficulty, + submissionDifficulty, + networkDifficulty: jobTemplate.blockData.networkDifficulty, + nonce: share.nonce, + ntime: share.ntime, + version: share.version, + extraNonce2: share.extraNonce2, + isBlockCandidate: updatedJobBlock != null, + blockSubmissionResult, + }); await this.statistics.addShares(this.clientEntity, jobDifficulty); const now = new Date(); - this.clientService.heartbeatBulkAsync(this.clientEntity.id, this.statistics.hashRate, now); this.clientEntity.updatedAt = now; + this.clientEntity.hashRate = this.statistics.hashRate; + await this.updateClientPresence(now); if (submissionDifficulty > this.clientEntity.bestDifficulty) { await this.clientService.updateBestDifficultyIfHigher(this.clientEntity.id, submissionDifficulty); @@ -717,6 +754,7 @@ export class StratumV2Client { submissionDifficulty, this.userAgent, ); + await this.updateClientPresence(new Date()); } } @@ -1085,12 +1123,35 @@ export class StratumV2Client { startTime: new Date(), bestDifficulty: 0, }); + await this.updateClientPresence(new Date()); })(); } await this.creatingEntity; } + private async updateClientPresence(lastSeen: Date): Promise { + if (this.clientEntity == null) { + return; + } + + try { + await this.redisMessagingService?.setClientPresence({ + clientId: this.clientEntity.id, + address: this.clientEntity.address, + clientName: this.clientEntity.clientName, + sessionId: this.clientEntity.sessionId, + userAgent: this.clientEntity.userAgent, + startTime: new Date(this.clientEntity.startTime).toISOString(), + lastSeen: lastSeen.toISOString(), + hashRate: this.statistics.hashRate, + bestDifficulty: Number(this.clientEntity.bestDifficulty ?? 0), + }); + } catch (error) { + console.error(`Failed to update SV2 client presence: ${error.message}`); + } + } + private parseUserIdentity(userIdentity: string): { address: string; workerName: string } { const parts = userIdentity.split('.'); const rawAddress = parts[0] ?? ''; diff --git a/src/scripts/run-migrations.ts b/src/scripts/run-migrations.ts new file mode 100644 index 0000000..4145ad1 --- /dev/null +++ b/src/scripts/run-migrations.ts @@ -0,0 +1,16 @@ +import { AppDataSource } from '../data-source'; + +async function main() { + await AppDataSource.initialize(); + try { + const migrations = await AppDataSource.runMigrations(); + console.log(`Ran ${migrations.length} database migrations`); + } finally { + await AppDataSource.destroy(); + } +} + +main().catch(error => { + console.error('Database migration failed:', error); + process.exit(1); +}); diff --git a/src/scripts/start-docker.ts b/src/scripts/start-docker.ts new file mode 100644 index 0000000..ecd0a98 --- /dev/null +++ b/src/scripts/start-docker.ts @@ -0,0 +1,68 @@ +import { spawn } from 'child_process'; +import * as net from 'net'; + +async function main() { + await waitForTcp(process.env.DB_HOST, parseInt(process.env.DB_PORT ?? '5432', 10), 'TimescaleDB'); + const redisUrl = new URL(process.env.REDIS_URL ?? 'redis://redis:6379'); + await waitForTcp(redisUrl.hostname, parseInt(redisUrl.port || '6379', 10), 'Redis'); + await run('node', ['dist/scripts/run-migrations.js']); + + if (process.env.PM2_ENABLED === 'false') { + await run('node', ['dist/main.js'], true); + return; + } + + await run('./node_modules/.bin/pm2-runtime', ['ecosystem.config.js'], true); +} + +function waitForTcp(host: string, port: number, label: string): Promise { + const deadline = Date.now() + parseInt(process.env.STARTUP_WAIT_TIMEOUT_MS ?? '60000', 10); + + return new Promise((resolve, reject) => { + const attempt = () => { + const socket = net.createConnection({ host, port }); + socket.once('connect', () => { + socket.destroy(); + console.log(`${label} is reachable at ${host}:${port}`); + resolve(); + }); + socket.once('error', () => { + socket.destroy(); + if (Date.now() > deadline) { + reject(new Error(`${label} was not reachable at ${host}:${port}`)); + return; + } + setTimeout(attempt, 1000); + }); + }; + + attempt(); + }); +} + +function run(command: string, args: string[], inherit = false): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: inherit ? 'inherit' : 'pipe', + env: process.env, + }); + + if (!inherit) { + child.stdout?.on('data', data => process.stdout.write(data)); + child.stderr?.on('data', data => process.stderr.write(data)); + } + + child.once('exit', code => { + if (code === 0) { + resolve(); + return; + } + reject(new Error(`${command} ${args.join(' ')} exited with ${code}`)); + }); + }); +} + +main().catch(error => { + console.error(error); + process.exit(1); +}); diff --git a/src/services/app.service.ts b/src/services/app.service.ts index 8db9369..49a6fe2 100644 --- a/src/services/app.service.ts +++ b/src/services/app.service.ts @@ -1,21 +1,15 @@ import { Injectable, OnModuleInit } from '@nestjs/common'; -import { DataSource } from 'typeorm'; import { UserAgentReportService } from '../ORM/_views/user-agent-report/user-agent-report.service'; -import { ClientStatisticsService } from '../ORM/client-statistics/client-statistics.service'; import { ClientService } from '../ORM/client/client.service'; -import { HomeGraphService } from '../ORM/home-graph/home-graph.service'; import { RpcBlockService } from '../ORM/rpc-block/rpc-block.service'; @Injectable() export class AppService implements OnModuleInit { constructor( - private readonly clientStatisticsService: ClientStatisticsService, private readonly clientService: ClientService, private readonly rpcBlockService: RpcBlockService, - private readonly homeGraphService: HomeGraphService, - private readonly dataSource: DataSource, private readonly userAgentReportService: UserAgentReportService ) { @@ -25,26 +19,14 @@ export class AppService implements OnModuleInit { if (process.env.MASTER == 'true') { setInterval(async () => { - await this.deleteOldStatistics(); + await this.deleteOldClients(); }, 1000 * 60 * 60); - setInterval(async () => { - console.log('Killing dead clients'); - while (await this.clientService.killDeadClients()) { - - } - console.log('Finished killing clients'); - }, 1000 * 60 * 5); - setInterval(async () => { console.log('Deleting Old Blocks'); await this.rpcBlockService.deleteOldBlocks(); }, 1000 * 60 * 60 * 24); - setInterval(async () => { - await this.updateChart(); - }, 1000 * 60 * 10); - setInterval(async () => { console.log('Refreshing user agent report view') await this.userAgentReportService.refreshReport(); @@ -52,61 +34,13 @@ export class AppService implements OnModuleInit { }, 1000 * 60 * 5); } - - setInterval(async () => { - //console.log('Bulk update client stats'); - await this.clientStatisticsService.doBulkAsyncUpdate(); - }, 1000 * 30); - - setInterval(async () => { - //console.log('Bulk update client stats'); - await this.clientService.doBulkHeartbeatUpdate(); - }, 1000 * 30); - - } - private async deleteOldStatistics() { - console.log('Deleting statistics'); + private async deleteOldClients() { + console.log('Deleting old clients'); - const deletedStatistics = await this.clientStatisticsService.deleteOldStatistics(); - console.log(`Deleted ${deletedStatistics.affected} old statistics`); const deletedClients = await this.clientService.deleteOldClients(); console.log(`Deleted ${deletedClients.affected} old clients`); } - - - private async updateChart() { - console.log('Updating Chart'); - - const latestGraphUpdate = await this.homeGraphService.getLatestTime(); - - - const data = await this.dataSource.query(` - SELECT - time AS label, - ROUND(((SUM(shares) * 4294967296) / 600)) AS data - FROM - client_statistics_entity AS entry - WHERE - entry.time > ${latestGraphUpdate.getTime()} - GROUP BY - time - ORDER BY - time - LIMIT 144; - `) - - console.log(`Fetched ${data.length} rows`); - - if (data.length < 2) { - return; - } - - const result = data.slice(0, data.length - 1); - - this.homeGraphService.save(result); - - } -} \ No newline at end of file +} diff --git a/src/services/bitcoin-rpc.service.ts b/src/services/bitcoin-rpc.service.ts index c661778..4cb36fc 100644 --- a/src/services/bitcoin-rpc.service.ts +++ b/src/services/bitcoin-rpc.service.ts @@ -7,7 +7,7 @@ import * as zmq from 'zeromq'; import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate'; import { IMiningInfo } from '../models/bitcoin-rpc/IMiningInfo'; -import * as PGPubsub from 'pg-pubsub'; +import { RedisMessagingService } from './redis-messaging.service'; @Injectable() export class BitcoinRpcService implements OnModuleInit { @@ -15,7 +15,6 @@ export class BitcoinRpcService implements OnModuleInit { private client: AxiosInstance; private _newBlockTemplate$: BehaviorSubject = new BehaviorSubject(undefined); - private pubsubInstance: PGPubsub; private resetTemplateInterval$ = new Subject(); private rpcRequestId = 0; @@ -24,16 +23,14 @@ export class BitcoinRpcService implements OnModuleInit { constructor( private readonly configService: ConfigService, - private rpcBlockService: RpcBlockService + private rpcBlockService: RpcBlockService, + private readonly redisMessagingService: RedisMessagingService ) { } async onModuleInit() { - this.pubsubInstance = new PGPubsub('postgres://' + this.configService.get('DB_USERNAME') + ':' + this.configService.get('DB_PASSWORD') + '@' + this.configService.get('DB_HOST') + ':' + this.configService.get('DB_PORT') + '/' + this.configService.get('DB_DATABASE')) - - const url = this.configService.get('BITCOIN_RPC_URL'); const user = this.configService.get('BITCOIN_RPC_USER'); const pass = this.configService.get('BITCOIN_RPC_PASSWORD'); @@ -60,11 +57,10 @@ export class BitcoinRpcService implements OnModuleInit { console.log(`MASTER? ${process.env.MASTER}`) if (process.env.MASTER != 'true') { - this.pubsubInstance.addChannel('miningInfo', async (miningInfo: IMiningInfo) => { - //console.log('PG Sub. new template'); + await this.loadLatestTemplateForWorker(); + await this.redisMessagingService.subscribeMiningInfoUpdates(async (miningInfo: IMiningInfo) => { this.miningInfo = miningInfo; - const savedBlockTemplate = await this.rpcBlockService.getSavedBlockTemplate(miningInfo.blocks); - this._newBlockTemplate$.next(JSON.parse(savedBlockTemplate.data)); + await this.loadTemplateForWorker(miningInfo.blocks); }); } else { console.log('Using ZMQ'); @@ -109,7 +105,36 @@ export class BitcoinRpcService implements OnModuleInit { public async getAndBroadcastLatestTemplate() { const blockTemplate = await this.loadBlockTemplate(this.miningInfo.blocks); this._newBlockTemplate$.next(blockTemplate); - await this.pubsubInstance.publish('miningInfo', this.miningInfo); + await this.redisMessagingService.setLatestMiningInfo(this.miningInfo); + await this.redisMessagingService.setBlockTemplate(this.miningInfo.blocks, blockTemplate); + await this.redisMessagingService.publishMiningInfoUpdate(this.miningInfo); + } + + private async loadLatestTemplateForWorker() { + const latestMiningInfo = await this.redisMessagingService.getLatestMiningInfo(); + if (latestMiningInfo != null) { + this.miningInfo = latestMiningInfo; + await this.loadTemplateForWorker(latestMiningInfo.blocks); + return; + } + + const latestBlockTemplate = await this.redisMessagingService.getLatestBlockTemplate(); + if (latestBlockTemplate != null) { + this._newBlockTemplate$.next(latestBlockTemplate); + } + } + + private async loadTemplateForWorker(blockHeight: number) { + const redisBlockTemplate = await this.redisMessagingService.getBlockTemplate(blockHeight); + if (redisBlockTemplate != null) { + this._newBlockTemplate$.next(redisBlockTemplate); + return; + } + + const savedBlockTemplate = await this.rpcBlockService.getSavedBlockTemplate(blockHeight); + if (savedBlockTemplate?.data != null) { + this._newBlockTemplate$.next(JSON.parse(savedBlockTemplate.data)); + } } private async loadBlockTemplate(blockHeight: number) { diff --git a/src/services/redis-messaging.module.ts b/src/services/redis-messaging.module.ts new file mode 100644 index 0000000..c804ea3 --- /dev/null +++ b/src/services/redis-messaging.module.ts @@ -0,0 +1,12 @@ +import { Global, Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; + +import { RedisMessagingService } from './redis-messaging.service'; + +@Global() +@Module({ + imports: [ConfigModule], + providers: [RedisMessagingService], + exports: [RedisMessagingService], +}) +export class RedisMessagingModule { } diff --git a/src/services/redis-messaging.service.spec.ts b/src/services/redis-messaging.service.spec.ts new file mode 100644 index 0000000..b35366f --- /dev/null +++ b/src/services/redis-messaging.service.spec.ts @@ -0,0 +1,182 @@ +import { ConfigService } from '@nestjs/config'; +import { createClient } from 'redis'; + +import { RedisMessagingService } from './redis-messaging.service'; + +jest.mock('redis', () => ({ + createClient: jest.fn(), +})); + +describe('RedisMessagingService', () => { + let clients: any[]; + let service: RedisMessagingService; + + beforeEach(() => { + store.clear(); + sets.clear(); + clientsByRole.publisher = null; + clientsByRole.subscriber = null; + clients = [createRedisClient(), createRedisClient()]; + (createClient as jest.Mock).mockImplementation(() => clients.shift()); + service = new RedisMessagingService({ + get: jest.fn((key: string) => key === 'REDIS_URL' ? 'redis://test-redis:6379' : null), + } as unknown as ConfigService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should store and replay latest mining info and block templates', async () => { + await service.connect(); + + await service.setLatestMiningInfo({ blocks: 900000 } as any); + await service.setBlockTemplate(900000, { height: 900000, transactions: [] } as any); + + expect(await service.getLatestMiningInfo()).toEqual({ blocks: 900000 }); + expect(await service.getBlockTemplate(900000)).toEqual({ height: 900000, transactions: [] }); + expect(await service.getLatestBlockTemplate()).toEqual({ height: 900000, transactions: [] }); + }); + + it('should publish and subscribe to mining info updates', async () => { + await service.connect(); + const handler = jest.fn().mockResolvedValue(undefined); + + await service.subscribeMiningInfoUpdates(handler); + await service.publishMiningInfoUpdate({ blocks: 900001 } as any); + + expect(handler).toHaveBeenCalledWith({ blocks: 900001 }); + }); + + it('should ignore malformed pubsub messages', async () => { + await service.connect(); + const handler = jest.fn(); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + + await service.subscribeMiningInfoUpdates(handler); + await clientsByRole.subscriber.callback('{bad json'); + + expect(handler).not.toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid Redis mining info update')); + consoleSpy.mockRestore(); + }); + + it('should store, index, and remove client presence', async () => { + await service.connect(); + + await service.setClientPresence({ + clientId: '00000000-0000-4000-8000-000000000001', + address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', + clientName: 'worker', + sessionId: '57a6f098', + userAgent: 'bitaxe', + startTime: '2026-06-07T12:00:00.000Z', + lastSeen: '2026-06-07T12:01:00.000Z', + hashRate: 100, + bestDifficulty: 200, + }); + + expect(await service.getClientPresence('00000000-0000-4000-8000-000000000001')) + .toEqual(expect.objectContaining({ + clientId: '00000000-0000-4000-8000-000000000001', + address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', + hashRate: 100, + bestDifficulty: 200, + })); + expect(await service.getClientPresenceByAddress('tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4')) + .toHaveLength(1); + expect(await service.getAllClientPresence()).toHaveLength(1); + + await service.removeClientPresence('00000000-0000-4000-8000-000000000001'); + + expect(await service.getAllClientPresence()).toHaveLength(0); + }); + + it('should clear all client presence keys', async () => { + await service.connect(); + + await service.setClientPresence({ + clientId: '00000000-0000-4000-8000-000000000001', + address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', + clientName: 'worker', + sessionId: '57a6f098', + userAgent: 'bitaxe', + startTime: '2026-06-07T12:00:00.000Z', + lastSeen: '2026-06-07T12:01:00.000Z', + hashRate: 100, + bestDifficulty: 200, + }); + + await service.clearClientPresence(); + + expect(await service.getAllClientPresence()).toHaveLength(0); + expect([...store.keys()].filter(key => key.startsWith('client-presence'))).toHaveLength(0); + expect([...sets.keys()].filter(key => key.startsWith('client-presence'))).toHaveLength(0); + }); +}); + +const store = new Map(); +const sets = new Map>(); +const clientsByRole: { publisher?: any; subscriber?: any } = {}; + +function createRedisClient() { + const client = { + connect: jest.fn().mockResolvedValue(undefined), + quit: jest.fn().mockResolvedValue(undefined), + on: jest.fn(), + set: jest.fn((key: string, value: string) => { + store.set(key, value); + return Promise.resolve('OK'); + }), + setEx: jest.fn((key: string, seconds: number, value: string) => { + store.set(key, value); + return Promise.resolve('OK'); + }), + get: jest.fn((key: string) => Promise.resolve(store.get(key) ?? null)), + del: jest.fn((...args: (string | string[])[]) => { + const keys = args.flatMap(key => Array.isArray(key) ? key : [key]); + let deleted = 0; + keys.forEach(item => { + deleted += store.delete(item) ? 1 : 0; + deleted += sets.delete(item) ? 1 : 0; + }); + return Promise.resolve(deleted); + }), + sAdd: jest.fn((key: string, value: string) => { + const set = sets.get(key) ?? new Set(); + set.add(value); + sets.set(key, set); + return Promise.resolve(1); + }), + sRem: jest.fn((key: string, value: string) => { + const set = sets.get(key); + const deleted = set?.delete(value) ? 1 : 0; + return Promise.resolve(deleted); + }), + sMembers: jest.fn((key: string) => Promise.resolve([...sets.get(key) ?? []])), + scanIterator: jest.fn(async function* scanIterator({ MATCH }: { MATCH: string }) { + const prefix = MATCH.replace('*', ''); + for (const key of [...store.keys(), ...sets.keys()]) { + if (key.startsWith(prefix)) { + yield key; + } + } + }), + publish: jest.fn((channel: string, value: string) => { + void clientsByRole.subscriber?.callback(value); + return Promise.resolve(1); + }), + subscribe: jest.fn((channel: string, callback: (message: string) => Promise) => { + clientsByRole.subscriber.callback = callback; + return Promise.resolve(); + }), + }; + + if (clientsByRole.publisher == null) { + clientsByRole.publisher = client; + } else { + clientsByRole.subscriber = client; + } + + return client; +} diff --git a/src/services/redis-messaging.service.ts b/src/services/redis-messaging.service.ts new file mode 100644 index 0000000..25d5a55 --- /dev/null +++ b/src/services/redis-messaging.service.ts @@ -0,0 +1,291 @@ +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createClient, RedisClientType } from 'redis'; + +import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate'; +import { IMiningInfo } from '../models/bitcoin-rpc/IMiningInfo'; + +const MINING_INFO_CHANNEL = 'mining-info.updated'; +const MINING_INFO_KEY = 'mining-info:latest'; +const BLOCK_TEMPLATE_LATEST_KEY = 'block-template:latest'; +const CLIENT_PRESENCE_TTL_SECONDS = 180; +const blockTemplateKey = (height: number) => `block-template:${height}`; +const CLIENT_PRESENCE_ALL_KEY = 'client-presence:all'; +const clientPresenceKey = (clientId: string) => `client-presence:${clientId}`; +const clientPresenceAddressKey = (address: string) => `client-presence:address:${address}`; + +export interface ClientPresence { + clientId: string; + address: string; + clientName: string; + sessionId: string; + userAgent?: string | null; + startTime: string; + lastSeen: string; + hashRate: number; + bestDifficulty: number; +} + +@Injectable() +export class RedisMessagingService implements OnModuleInit, OnModuleDestroy { + private publisher: RedisClientType; + private subscriber: RedisClientType; + private connected = false; + private readonly clientPresenceTtlSeconds: number; + + constructor( + private readonly configService: ConfigService, + ) { + this.clientPresenceTtlSeconds = this.readPositiveInt('CLIENT_PRESENCE_TTL_SECONDS', CLIENT_PRESENCE_TTL_SECONDS); + } + + public async onModuleInit() { + await this.connect().catch(error => { + console.error(`Redis unavailable at startup: ${error.message}`); + }); + } + + public async onModuleDestroy() { + await Promise.all([ + this.publisher?.quit().catch(() => undefined), + this.subscriber?.quit().catch(() => undefined), + ]); + } + + public async connect() { + if (this.connected) { + return; + } + + const url = this.configService.get('REDIS_URL') ?? process.env.REDIS_URL ?? 'redis://localhost:6379'; + const socket = { + reconnectStrategy: (retries: number) => Math.min(retries * 250, 5000), + }; + this.publisher = createClient({ url, socket }); + this.subscriber = createClient({ url, socket }); + + this.publisher.on('error', error => console.error(`Redis publisher error: ${error.message}`)); + this.subscriber.on('error', error => console.error(`Redis subscriber error: ${error.message}`)); + this.publisher.on('end', () => { this.connected = false; }); + this.subscriber.on('end', () => { this.connected = false; }); + + await Promise.all([this.publisher.connect(), this.subscriber.connect()]); + this.connected = true; + } + + public async publishMiningInfoUpdate(miningInfo: IMiningInfo) { + if (!await this.ensureConnected()) { + return; + } + await this.publisher.publish(MINING_INFO_CHANNEL, JSON.stringify(miningInfo)); + } + + public async subscribeMiningInfoUpdates(handler: (miningInfo: IMiningInfo) => Promise) { + if (!await this.ensureConnected()) { + return; + } + await this.subscriber.subscribe(MINING_INFO_CHANNEL, async message => { + try { + await handler(JSON.parse(message)); + } catch (error) { + console.error(`Invalid Redis mining info update: ${error.message}`); + } + }); + } + + public async setLatestMiningInfo(miningInfo: IMiningInfo) { + if (!await this.ensureConnected()) { + return; + } + await this.publisher.set(MINING_INFO_KEY, JSON.stringify(miningInfo)); + } + + public async getLatestMiningInfo(): Promise { + if (!await this.ensureConnected()) { + return null; + } + const value = await this.publisher.get(MINING_INFO_KEY); + return value == null ? null : JSON.parse(value as string); + } + + public async setBlockTemplate(height: number, blockTemplate: IBlockTemplate) { + if (!await this.ensureConnected()) { + return; + } + const serialized = JSON.stringify(blockTemplate); + await Promise.all([ + this.publisher.set(blockTemplateKey(height), serialized), + this.publisher.set(BLOCK_TEMPLATE_LATEST_KEY, serialized), + ]); + } + + public async getBlockTemplate(height: number): Promise { + if (!await this.ensureConnected()) { + return null; + } + const value = await this.publisher.get(blockTemplateKey(height)); + return value == null ? null : JSON.parse(value as string); + } + + public async getLatestBlockTemplate(): Promise { + if (!await this.ensureConnected()) { + return null; + } + const value = await this.publisher.get(BLOCK_TEMPLATE_LATEST_KEY); + return value == null ? null : JSON.parse(value as string); + } + + public async setClientPresence(presence: ClientPresence): Promise { + if (!await this.ensureConnected()) { + return; + } + + const serialized = JSON.stringify({ + ...presence, + userAgent: presence.userAgent ?? null, + hashRate: Number.isFinite(Number(presence.hashRate)) ? Number(presence.hashRate) : 0, + bestDifficulty: Number.isFinite(Number(presence.bestDifficulty)) ? Number(presence.bestDifficulty) : 0, + }); + + await Promise.all([ + this.publisher.setEx(clientPresenceKey(presence.clientId), this.clientPresenceTtlSeconds, serialized), + this.publisher.sAdd(CLIENT_PRESENCE_ALL_KEY, presence.clientId), + this.publisher.sAdd(clientPresenceAddressKey(presence.address), presence.clientId), + ]); + } + + public async removeClientPresence(clientId: string, address?: string): Promise { + if (!await this.ensureConnected()) { + return; + } + + let resolvedAddress = address; + if (resolvedAddress == null) { + const presence = await this.getClientPresence(clientId); + resolvedAddress = presence?.address; + } + + const removals: Promise[] = [ + this.publisher.del(clientPresenceKey(clientId)), + this.publisher.sRem(CLIENT_PRESENCE_ALL_KEY, clientId), + ]; + if (resolvedAddress != null) { + removals.push(this.publisher.sRem(clientPresenceAddressKey(resolvedAddress), clientId)); + } + + await Promise.all(removals); + } + + public async getClientPresence(clientId: string): Promise { + if (!await this.ensureConnected()) { + return null; + } + + const value = await this.publisher.get(clientPresenceKey(clientId)); + return this.parseClientPresence(value); + } + + public async getClientPresenceByAddress(address: string): Promise { + if (!await this.ensureConnected()) { + return []; + } + + return this.getPresenceFromSet(clientPresenceAddressKey(address)); + } + + public async getAllClientPresence(): Promise { + if (!await this.ensureConnected()) { + return []; + } + + return this.getPresenceFromSet(CLIENT_PRESENCE_ALL_KEY); + } + + public async clearClientPresence(): Promise { + if (!await this.ensureConnected()) { + return; + } + + const batch: string[] = []; + for await (const keyOrKeys of (this.publisher as any).scanIterator({ MATCH: 'client-presence*', COUNT: 1000 })) { + const keys = Array.isArray(keyOrKeys) ? keyOrKeys : [keyOrKeys]; + batch.push(...keys.map(key => key as string)); + if (batch.length >= 500) { + await this.deleteKeys(batch.splice(0)); + } + } + + await this.deleteKeys(batch); + } + + private async ensureConnected(): Promise { + if (!this.connected) { + try { + await this.connect(); + } catch (error) { + console.error(`Redis messaging degraded: ${error.message}`); + return false; + } + } + return true; + } + + private async getPresenceFromSet(setKey: string): Promise { + const clientIds = await this.publisher.sMembers(setKey); + if (clientIds.length === 0) { + return []; + } + + const presences = await Promise.all(clientIds.map(async clientId => { + const presence = await this.getClientPresence(clientId); + if (presence == null) { + await this.publisher.sRem(setKey, clientId); + if (setKey !== CLIENT_PRESENCE_ALL_KEY) { + await this.publisher.sRem(CLIENT_PRESENCE_ALL_KEY, clientId); + } + } + return presence; + })); + + return presences.filter((presence): presence is ClientPresence => presence != null); + } + + private parseClientPresence(value: unknown): ClientPresence | null { + if (value == null) { + return null; + } + + try { + const parsed = JSON.parse(value as string); + if (parsed?.clientId == null || parsed?.address == null) { + return null; + } + return { + clientId: parsed.clientId, + address: parsed.address, + clientName: parsed.clientName ?? 'default', + sessionId: parsed.sessionId ?? parsed.clientId, + userAgent: parsed.userAgent ?? null, + startTime: parsed.startTime, + lastSeen: parsed.lastSeen, + hashRate: Number(parsed.hashRate ?? 0), + bestDifficulty: Number(parsed.bestDifficulty ?? 0), + }; + } catch (error) { + console.error(`Invalid Redis client presence: ${error.message}`); + return null; + } + } + + private readPositiveInt(name: string, defaultValue: number): number { + const value = Number(this.configService.get(name) ?? process.env[name]); + return Number.isInteger(value) && value > 0 ? value : defaultValue; + } + + private async deleteKeys(keys: string[]): Promise { + if (keys.length === 0) { + return; + } + await (this.publisher as any).del(...keys); + } +} diff --git a/src/services/stratum-v1.service.spec.ts b/src/services/stratum-v1.service.spec.ts index 32e0738..057d0e6 100644 --- a/src/services/stratum-v1.service.spec.ts +++ b/src/services/stratum-v1.service.spec.ts @@ -11,7 +11,9 @@ describe('StratumV1Service', () => { let service: StratumV1Service; let clientService; + let userAgentReportService; let stratumV2Service; + let redisMessagingService; let consoleLogSpy: jest.SpyInstance; let consoleWarnSpy: jest.SpyInstance; @@ -20,10 +22,16 @@ describe('StratumV1Service', () => { clientService = { deleteAll: jest.fn().mockResolvedValue(undefined) }; + userAgentReportService = { + refreshReport: jest.fn().mockResolvedValue(undefined) + }; stratumV2Service = { ensureInitialized: jest.fn().mockResolvedValue(undefined), createClient: jest.fn() }; + redisMessagingService = { + clearClientPresence: jest.fn().mockResolvedValue(undefined) + }; service = new StratumV1Service( {} as any, clientService, @@ -32,8 +40,10 @@ describe('StratumV1Service', () => { {} as any, {} as any, {} as any, - {} as any, - stratumV2Service as any + stratumV2Service as any, + userAgentReportService as any, + undefined, + redisMessagingService as any ); consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); @@ -61,6 +71,8 @@ describe('StratumV1Service', () => { jest.runOnlyPendingTimers(); expect(clientService.deleteAll).toHaveBeenCalled(); + expect(redisMessagingService.clearClientPresence).toHaveBeenCalled(); + expect(userAgentReportService.refreshReport).toHaveBeenCalled(); expect(startSocketServerSpy).not.toHaveBeenCalled(); expect(startSecureSocketServerSpy).not.toHaveBeenCalled(); expect(consoleLogSpy).toHaveBeenCalledWith('Master process skipping Stratum socket listeners'); @@ -78,6 +90,7 @@ describe('StratumV1Service', () => { jest.advanceTimersByTime(10000); expect(clientService.deleteAll).not.toHaveBeenCalled(); + expect(userAgentReportService.refreshReport).not.toHaveBeenCalled(); expect(startSocketServerSpy).toHaveBeenCalledWith(3333); expect(startSocketServerSpy).toHaveBeenCalledWith(3334); expect(startSecureSocketServerSpy).toHaveBeenCalledWith(4333); diff --git a/src/services/stratum-v1.service.ts b/src/services/stratum-v1.service.ts index aa19de7..8875c7c 100644 --- a/src/services/stratum-v1.service.ts +++ b/src/services/stratum-v1.service.ts @@ -5,12 +5,14 @@ import { monitorEventLoopDelay } from 'perf_hooks'; import { StratumV1Client } from '../models/StratumV1Client'; import { StratumV2Client } from '../models/StratumV2Client'; +import { UserAgentReportService } from '../ORM/_views/user-agent-report/user-agent-report.service'; import { AddressSettingsService } from '../ORM/address-settings/address-settings.service'; import { BlocksService } from '../ORM/blocks/blocks.service'; -import { ClientStatisticsService } from '../ORM/client-statistics/client-statistics.service'; import { ClientService } from '../ORM/client/client.service'; +import { ShareAccountingService } from '../ORM/share-accounting/share-accounting.service'; import { BitcoinRpcService } from './bitcoin-rpc.service'; import { NotificationService } from './notification.service'; +import { RedisMessagingService } from './redis-messaging.service'; import { StratumV1JobsService } from './stratum-v1-jobs.service'; import { StratumV2Service } from './stratum-v2.service'; @@ -51,13 +53,15 @@ export class StratumV1Service implements OnModuleInit { constructor( private readonly bitcoinRpcService: BitcoinRpcService, private readonly clientService: ClientService, - private readonly clientStatisticsService: ClientStatisticsService, private readonly notificationService: NotificationService, private readonly blocksService: BlocksService, private readonly configService: ConfigService, private readonly stratumV1JobsService: StratumV1JobsService, private readonly addressSettingsService: AddressSettingsService, - private readonly stratumV2Service: StratumV2Service + private readonly stratumV2Service: StratumV2Service, + private readonly userAgentReportService: UserAgentReportService, + private readonly shareAccountingService?: ShareAccountingService, + private readonly redisMessagingService?: RedisMessagingService ) { } @@ -66,6 +70,8 @@ export class StratumV1Service implements OnModuleInit { if (process.env.MASTER == 'true') { await this.clientService.deleteAll(); + await this.redisMessagingService?.clearClientPresence(); + await this.userAgentReportService.refreshReport(); console.log('Master process skipping Stratum socket listeners'); return; } @@ -191,11 +197,12 @@ export class StratumV1Service implements OnModuleInit { this.stratumV1JobsService, this.bitcoinRpcService, this.clientService, - this.clientStatisticsService, this.notificationService, this.blocksService, this.configService, - this.addressSettingsService + this.addressSettingsService, + this.shareAccountingService, + this.redisMessagingService ); } diff --git a/src/services/stratum-v2.service.ts b/src/services/stratum-v2.service.ts index 7b9bd4d..43491ec 100644 --- a/src/services/stratum-v2.service.ts +++ b/src/services/stratum-v2.service.ts @@ -5,8 +5,8 @@ import { Server, Socket } from 'net'; import { AddressSettingsService } from '../ORM/address-settings/address-settings.service'; import { BlocksService } from '../ORM/blocks/blocks.service'; -import { ClientStatisticsService } from '../ORM/client-statistics/client-statistics.service'; import { ClientService } from '../ORM/client/client.service'; +import { ShareAccountingService } from '../ORM/share-accounting/share-accounting.service'; import { StratumV2Client } from '../models/StratumV2Client'; import { encodeSv2AuthorityPublicKey } from '../models/sv2/sv2-authority-key'; import { Sv2ExtranonceManager } from '../models/sv2/sv2-extranonce-manager'; @@ -19,6 +19,7 @@ import { } from '../models/sv2/sv2-noise'; import { BitcoinRpcService } from './bitcoin-rpc.service'; import { NotificationService } from './notification.service'; +import { RedisMessagingService } from './redis-messaging.service'; import { StratumV1JobsService } from './stratum-v1-jobs.service'; @Injectable() @@ -35,12 +36,13 @@ export class StratumV2Service implements OnModuleInit { constructor( private readonly bitcoinRpcService: BitcoinRpcService, private readonly clientService: ClientService, - private readonly clientStatisticsService: ClientStatisticsService, private readonly notificationService: NotificationService, private readonly blocksService: BlocksService, private readonly configService: ConfigService, private readonly stratumV1JobsService: StratumV1JobsService, private readonly addressSettingsService: AddressSettingsService, + private readonly shareAccountingService?: ShareAccountingService, + private readonly redisMessagingService?: RedisMessagingService, ) {} public async onModuleInit(): Promise { @@ -77,11 +79,12 @@ export class StratumV2Service implements OnModuleInit { this.stratumV1JobsService, this.bitcoinRpcService, this.clientService, - this.clientStatisticsService, this.notificationService, this.blocksService, this.configService, this.addressSettingsService, + this.shareAccountingService, + this.redisMessagingService, ); } diff --git a/test/jest-integration.json b/test/jest-integration.json new file mode 100644 index 0000000..89c06c6 --- /dev/null +++ b/test/jest-integration.json @@ -0,0 +1,10 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": "..", + "testEnvironment": "node", + "testRegex": "test/.*\\.integration-spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "testTimeout": 60000 +} diff --git a/test/timescale-redis.integration-spec.ts b/test/timescale-redis.integration-spec.ts new file mode 100644 index 0000000..a82c606 --- /dev/null +++ b/test/timescale-redis.integration-spec.ts @@ -0,0 +1,303 @@ +import { ConfigService } from '@nestjs/config'; +import { DataSource } from 'typeorm'; + +import { AcceptedShareEntity } from '../src/ORM/accepted-share/accepted-share.entity'; +import { UserAgentReportService } from '../src/ORM/_views/user-agent-report/user-agent-report.service'; +import { UserAgentReportView } from '../src/ORM/_views/user-agent-report/user-agent-report.view'; +import { ClientStatisticsService } from '../src/ORM/client-statistics/client-statistics.service'; +import { ClientEntity } from '../src/ORM/client/client.entity'; +import { ShareAccountingService } from '../src/ORM/share-accounting/share-accounting.service'; +import { RedisMessagingService } from '../src/services/redis-messaging.service'; + +describe('TimescaleDB and Redis integration', () => { + let dataSource: DataSource; + let redisMessagingService: RedisMessagingService; + + beforeAll(async () => { + process.env.DB_HOST ??= '127.0.0.1'; + process.env.DB_PORT ??= '15432'; + process.env.DB_USERNAME ??= 'public_pool'; + process.env.DB_PASSWORD ??= 'public_pool'; + process.env.DB_DATABASE ??= 'public_pool_test'; + process.env.REDIS_URL ??= 'redis://127.0.0.1:16379'; + + const imported = await import('../src/data-source'); + dataSource = imported.AppDataSource; + if (!dataSource.isInitialized) { + await dataSource.initialize(); + } + await dataSource.runMigrations(); + + redisMessagingService = new RedisMessagingService({ + get: jest.fn((key: string) => key === 'REDIS_URL' ? process.env.REDIS_URL : null), + } as unknown as ConfigService); + await redisMessagingService.connect(); + }); + + afterAll(async () => { + await redisMessagingService?.onModuleDestroy(); + if (dataSource?.isInitialized) { + await dataSource.destroy(); + } + }); + + beforeEach(async () => { + await dataSource.query(`DELETE FROM accepted_share_entity`); + await dataSource.query(`DELETE FROM client_entity`); + await dataSource.query(`REFRESH MATERIALIZED VIEW user_agent_report_view`); + }); + + it('should create Timescale extension, hypertable, continuous aggregates, and operational policies', async () => { + const extensions = await dataSource.query(`SELECT extname FROM pg_extension WHERE extname = 'timescaledb'`); + expect(extensions).toHaveLength(1); + + const hypertables = await dataSource.query(` + SELECT hypertable_name, compression_enabled + FROM timescaledb_information.hypertables + WHERE hypertable_name = 'accepted_share_entity' + `); + expect(hypertables).toHaveLength(1); + expect(hypertables[0].compression_enabled).toBe(true); + + const aggregates = await dataSource.query(` + SELECT view_name + FROM timescaledb_information.continuous_aggregates + WHERE view_name IN ('accepted_share_10m', 'accepted_share_1h', 'accepted_share_1d') + ORDER BY view_name + `); + expect(aggregates.map(row => row.view_name)).toEqual([ + 'accepted_share_10m', + 'accepted_share_1d', + 'accepted_share_1h', + ]); + + const legacyTables = await dataSource.query(` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name IN ('client_statistics_entity', 'home_graph_entity') + `); + expect(legacyTables).toHaveLength(0); + + const policies = await dataSource.query(` + SELECT proc_name, schedule_interval::text AS schedule_interval + FROM timescaledb_information.jobs + WHERE hypertable_name = 'accepted_share_entity' + OR application_name LIKE 'Refresh Continuous Aggregate Policy%' + ORDER BY proc_name, schedule_interval + `); + expect(policies).toEqual(expect.arrayContaining([ + expect.objectContaining({ proc_name: 'policy_compression' }), + expect.objectContaining({ proc_name: 'policy_retention' }), + expect.objectContaining({ proc_name: 'policy_refresh_continuous_aggregate' }), + ])); + }); + + it('should persist accepted shares and refresh the 10 minute aggregate', async () => { + const client = await dataSource.getRepository(ClientEntity).save({ + address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', + clientName: 'worker', + sessionId: '57a6f098', + userAgent: 'integration-test', + startTime: new Date(), + bestDifficulty: 0, + hashRate: 0, + }); + const service = new ShareAccountingService(dataSource.getRepository(AcceptedShareEntity)); + const acceptedAt = new Date('2026-06-07T12:10:00Z'); + + await service.recordAcceptedShare({ + protocol: 'sv1', + acceptedAt, + address: client.address, + clientName: client.clientName, + sessionId: client.sessionId, + clientId: client.id, + jobId: '1', + jobTemplateId: '1', + blockHeight: 900000, + creditedDifficulty: 64, + submissionDifficulty: 128, + networkDifficulty: 100000, + nonce: 'ed460d91', + ntime: '64b3f3ec', + version: '20000000', + extraNonce2: 'c708000000000000', + isBlockCandidate: false, + blockSubmissionResult: null, + }); + + const rows = await dataSource.query(`SELECT COUNT(*)::int AS count FROM accepted_share_entity`); + expect(rows[0].count).toBeGreaterThanOrEqual(1); + + await dataSource.query(`CALL refresh_continuous_aggregate('accepted_share_10m', NULL, NULL)`); + const aggregateRows = await dataSource.query(` + SELECT "shares"::float AS shares, "acceptedCount"::int AS "acceptedCount" + FROM accepted_share_10m + WHERE "address" = $1 AND "clientName" = $2 + `, [client.address, client.clientName]); + + expect(aggregateRows).toEqual(expect.arrayContaining([ + expect.objectContaining({ shares: 64, acceptedCount: 1 }), + ])); + }); + + it('should exclude soft-deleted clients from the user-agent report', async () => { + const userAgent = `integration-reconnect-${Date.now()}`; + const repository = dataSource.getRepository(ClientEntity); + + const activeClient = await repository.save({ + address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', + clientName: 'active-worker', + sessionId: 'a1b2c3d4', + userAgent, + startTime: new Date(), + bestDifficulty: 10, + hashRate: 100, + }); + const staleClient = await repository.save({ + address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', + clientName: 'stale-worker', + sessionId: 'd4c3b2a1', + userAgent, + startTime: new Date(), + bestDifficulty: 20, + hashRate: 200, + }); + await repository.softDelete(staleClient.id); + + await dataSource.query(`REFRESH MATERIALIZED VIEW user_agent_report_view`); + const rows = await dataSource.query(` + SELECT "count"::int AS "count", "bestDifficulty"::float AS "bestDifficulty", "totalHashRate"::float AS "totalHashRate" + FROM user_agent_report_view + WHERE "userAgent" = $1 + `, [userAgent]); + + expect(rows).toEqual([{ + count: 1, + bestDifficulty: Number(activeClient.bestDifficulty), + totalHashRate: Number(activeClient.hashRate), + }]); + }); + + it('should serve live active user-agent reports without waiting for materialized refresh', async () => { + const userAgent = `integration-live-reconnect-${Date.now()}`; + const repository = dataSource.getRepository(ClientEntity); + const reportService = new UserAgentReportService( + dataSource.getRepository(UserAgentReportView), + repository, + redisMessagingService, + new ShareAccountingService(dataSource.getRepository(AcceptedShareEntity)), + ); + + const activeClient = await repository.save({ + address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', + clientName: 'active-live-worker', + sessionId: '91b2c3d4', + userAgent, + startTime: new Date(), + bestDifficulty: 11, + hashRate: 0, + }); + await repository.save({ + address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', + clientName: 'stale-live-worker', + sessionId: '92b2c3d4', + userAgent, + startTime: new Date(), + bestDifficulty: 22, + hashRate: 200, + }); + + await redisMessagingService.setClientPresence({ + clientId: activeClient.id, + address: activeClient.address, + clientName: activeClient.clientName, + sessionId: activeClient.sessionId, + userAgent, + startTime: activeClient.startTime.toISOString(), + lastSeen: new Date().toISOString(), + bestDifficulty: Number(activeClient.bestDifficulty), + hashRate: 0, + }); + + const rows = await reportService.getReport(); + expect(rows).toEqual(expect.arrayContaining([{ + userAgent, + count: '1', + bestDifficulty: Number(activeClient.bestDifficulty), + totalHashRate: '0', + }])); + }); + + it('should serve realtime chart and hashrate data from accepted shares', async () => { + const client = await dataSource.getRepository(ClientEntity).save({ + address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4', + clientName: 'worker', + sessionId: '8f3c5d2a', + userAgent: 'integration-test', + startTime: new Date(), + bestDifficulty: 0, + hashRate: 0, + }); + const accountingService = new ShareAccountingService(dataSource.getRepository(AcceptedShareEntity)); + const statisticsService = new ClientStatisticsService(dataSource); + const acceptedAt = new Date(); + + for (const [index, difficulty] of [64, 32].entries()) { + await accountingService.recordAcceptedShare({ + protocol: 'sv1', + acceptedAt: new Date(acceptedAt.getTime() + index), + address: client.address, + clientName: client.clientName, + sessionId: client.sessionId, + clientId: client.id, + jobId: `realtime-${index}`, + jobTemplateId: 'realtime-template', + blockHeight: 900001, + creditedDifficulty: difficulty, + submissionDifficulty: difficulty * 2, + networkDifficulty: 100000, + nonce: `nonce-${index}`, + ntime: '64b3f3ec', + version: '20000000', + extraNonce2: 'c708000000000000', + isBlockCandidate: false, + blockSubmissionResult: null, + }); + } + + const expectedHashRate = (96 * 4294967296) / 600; + const expectedChartData = Math.round(expectedHashRate).toString(); + expect(await statisticsService.getHashRateForGroup(client.address, client.clientName)) + .toBeCloseTo(expectedHashRate); + + const addressChart = await statisticsService.getChartDataForAddress(client.address); + const groupChart = await statisticsService.getChartDataForGroup(client.address, client.clientName); + const sessionChart = await statisticsService.getChartDataForSession(client.id); + const siteChart = await statisticsService.getChartDataForSite(); + + for (const chart of [addressChart, groupChart, sessionChart, siteChart]) { + expect(chart).toEqual(expect.arrayContaining([ + expect.objectContaining({ data: expectedChartData }), + ])); + } + }); + + it('should replay mining info and block templates through Redis', async () => { + await redisMessagingService.setLatestMiningInfo({ blocks: 900001 } as any); + await redisMessagingService.setBlockTemplate(900001, { height: 900001, transactions: [] } as any); + + expect(await redisMessagingService.getLatestMiningInfo()).toEqual({ blocks: 900001 }); + expect(await redisMessagingService.getBlockTemplate(900001)).toEqual({ height: 900001, transactions: [] }); + + const received: any[] = []; + await redisMessagingService.subscribeMiningInfoUpdates(async miningInfo => { + received.push(miningInfo); + }); + await redisMessagingService.publishMiningInfoUpdate({ blocks: 900002 } as any); + await new Promise(resolve => setTimeout(resolve, 100)); + + expect(received).toEqual([{ blocks: 900002 }]); + }); +});