diff --git a/.env.example b/.env.example index 318dcc9..2ef5254 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,19 @@ REACT_APP_WCA_CLIENT_ID=replace-me # Server config MONGO_URL=mongodb://mongo:27017/letscube REDIS_URL=redis://redis:6379 +POSTGRES_ENABLED=true +# DATABASE_URL can replace the individual PG* connection fields. +DATABASE_URL= +# Optional direct connection for Prisma migrations when DATABASE_URL is pooled. +DIRECT_DATABASE_URL= +PGHOST=postgres +PGPORT=5432 +PGDATABASE=letscube +PGUSER=letscube +POSTGRES_PASSWORD=replace-with-a-long-random-secret +PGSSL=false +PGSSL_REJECT_UNAUTHORIZED=true +PGSSL_CA= AUTH_SECRET=replace-with-a-long-random-secret SESSION_SECRET= AUTH_CALLBACK_URL=https://letscube.net/auth/callback @@ -20,6 +33,9 @@ WCA_SOURCE=https://www.worldcubeassociation.org WCA_CLIENT_ID=replace-me WCA_CLIENT_SECRET=replace-me CORS_ORIGINS=https://letscube.net,https://www.letscube.net +METRICS_ENABLED=true +METRICS_RETENTION_DAYS=90 +METRICS_HASH_SECRET=replace-with-a-different-long-random-secret # Nginx TLS mounts LETSENCRYPT_DIR=/etc/letsencrypt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7650e86..3d76d96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,26 @@ jobs: server: name: Server lint and unit tests runs-on: ubuntu-latest + env: + PGHOST: 127.0.0.1 + PGPORT: 5432 + PGDATABASE: letscube + PGUSER: letscube + PGPASSWORD: letscube + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_DB: letscube + POSTGRES_USER: letscube + POSTGRES_PASSWORD: letscube + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U letscube -d letscube" + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - name: Check out repository uses: actions/checkout@v4 @@ -24,6 +44,15 @@ jobs: - name: Install dependencies run: yarn install --frozen-lockfile + - name: Validate Prisma schema + run: yarn workspace letscube-server postgres:schema:validate + + - name: Apply PostgreSQL migrations + run: yarn workspace letscube-server postgres:migrate + + - name: Check PostgreSQL schema drift + run: yarn workspace letscube-server postgres:schema:check + - name: Run server lint run: yarn turbo run lint --filter=letscube-server diff --git a/Dockerfile b/Dockerfile index 3ee7e8e..0383245 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,7 +44,10 @@ ENV PORT=8080 ENV SOCKETIO_PORT=9000 WORKDIR /app -RUN groupadd --system letscube \ +RUN apt-get update \ + && apt-get install -y --no-install-recommends openssl \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system letscube \ && useradd --system --gid letscube --home-dir /app --shell /usr/sbin/nologin letscube COPY --chown=letscube:letscube server ./server diff --git a/README.md b/README.md index 5819086..363d1e4 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,37 @@ # Let's Cube -This is a Progressive Web app written in Node.JS, Express, MongoDB, socket.io, react, and material-ui. +This is a Progressive Web app written in Node.JS, Express, MongoDB, +PostgreSQL, socket.io, React, and Material UI. This project consists of a client and a server. ## Development: -Make sure Node.JS and Docker are installed. Docker Compose starts the local MongoDB and Redis services used by the app. +Make sure Node.JS and Docker are installed. Docker Compose starts the local +MongoDB, PostgreSQL, and Redis services used by the app. ``` git clone https://github.com/coder13/letscube.git cd letscube yarn install -docker compose up -d +docker compose -f docker-compose.yml up -d ``` -If MongoDB or Redis are already running locally on their default ports, stop them or skip Docker Compose. +The local PostgreSQL service is exposed on port `55433` to avoid collisions +with other projects. MongoDB remains the source of truth while PostgreSQL is a +non-blocking dual-write target. Prisma owns the PostgreSQL schema and migration +history. Apply committed migrations locally with: + +```bash +yarn workspace letscube-server postgres:migrate +``` + +Create schema changes during development with +`yarn workspace letscube-server postgres:migrate:dev`. Production Compose runs +`prisma migrate deploy` as a separate one-shot service, keeping schema changes +out of the API and Socket.IO startup paths. CI applies every committed migration +to PostgreSQL 17 and checks the resulting database for drift from +`server/prisma/schema.prisma`. **Server** @@ -33,3 +49,45 @@ yarn start:client ``` For more on the internals and contributing, check out the [wiki](https://github.com/coder13/LetsCube/wiki) + +## Metrics + +The server stores pseudonymous room and authentication events in both the +`metric_events` MongoDB collection and the PostgreSQL `analytics.events` table. +It records room creations, joins, join failures, leaves and visit duration, +accepted result counts, and authentication failures. Peak room users and peak +room solve counts are the maximum `activeUserCount` and `roomSolveCount` values +for each pseudonymous room. + +Raw events expire after 90 days by default. Set `METRICS_RETENTION_DAYS` to a +different positive number of days, or set `METRICS_ENABLED=false` to disable +collection. Production should set a dedicated `METRICS_HASH_SECRET`; when it is +not set, the session `AUTH_SECRET` is used. Changing this secret breaks the +ability to correlate pseudonymous users and rooms across the change. + +Metrics never include names, email addresses, WCA IDs, room names, passwords, +access codes, OAuth credentials, chat content, scramble text, or solve times. + +## PostgreSQL dual writes + +New MongoDB writes are mirrored into PostgreSQL without changing application +reads. PostgreSQL receives users and preferences, rooms and participant state, +attempts, durable solve results, and sanitized analytics events. OAuth access +tokens are deliberately not copied. Writes use deterministic UUIDs and upserts, +so retries and future backfills are idempotent. Live room saves mirror only the +attempts and results changed by that save; complete room snapshots are reserved +for explicit backfills. Changing a room event explicitly replaces that room's +PostgreSQL attempts so removed MongoDB attempts do not remain queryable. + +Solve penalties use dedicated boolean columns rather than JSON so histories and +statistics remain compact and index-friendly. User solve history is indexed by +creation time and solve ID for stable cursor pagination. + +Set `POSTGRES_ENABLED=false` to disable mirroring. Production should set +`PGHOST`, `PGDATABASE`, `PGUSER`, and `POSTGRES_PASSWORD`, or provide a +`DATABASE_URL`. When runtime traffic uses a pooled connection, set +`DIRECT_DATABASE_URL` to the direct connection used by Prisma Migrate. External +TLS connections can set `PGSSL=true` and provide a CA with `PGSSL_CA`; +certificate verification is enabled by default. PostgreSQL failures are logged +but do not fail the corresponding MongoDB-backed application operation during +this migration phase. diff --git a/client/package.json b/client/package.json index 0967463..d4f12c8 100644 --- a/client/package.json +++ b/client/package.json @@ -6,6 +6,7 @@ "@material-ui/core": "^4.11.2", "@material-ui/icons": "^4.11.2", "@material-ui/lab": "4.0.0-alpha.46", + "@material-ui/styles": "^4.11.5", "clsx": "1.1.0", "connected-react-router": "6.8.0", "date-fns": "^2.16.1", diff --git a/client/src/lib/Namespace.js b/client/src/lib/Namespace.js index bb2058e..e82eb79 100644 --- a/client/src/lib/Namespace.js +++ b/client/src/lib/Namespace.js @@ -1,5 +1,5 @@ // import io from 'socket.io-client'; -import * as Protocol from './protocol'; +import Protocol from './protocol'; Error.stackTraceLimit = Infinity; diff --git a/client/src/lib/events.js b/client/src/lib/events.js index 1f016a5..79bcb7f 100644 --- a/client/src/lib/events.js +++ b/client/src/lib/events.js @@ -1,145 +1,5 @@ -const Events = [ - { - id: '333', - scrambler: '333', - name: '3x3', - group: 'WCA', - }, - { - id: '222', - scrambler: '222', - name: '2x2', - group: 'WCA', - }, - { - id: '444', - scrambler: '444', - name: '4x4', - group: 'WCA', - }, - { - id: '555', - scrambler: '555', - name: '5x5', - group: 'WCA', - }, - { - id: '666', - scrambler: '666', - name: '6x6', - group: 'WCA', - }, - { - id: '777', - scrambler: '777', - name: '7x7', - group: 'WCA', - }, - { - id: '333bf', - scrambler: '333', - name: '3x3 Blindfolded', - group: 'WCA', - // }, { - // id: '333fm', - // scrambler: '333', - // name: '3x3x3 Fewest Moves', - }, - { - id: '333oh', - scrambler: '333', - name: '3x3 One-Handed', - group: 'WCA', - }, - { - id: '333ft', - scrambler: '333', - name: '3x3 With Feet', - group: 'WCA', - }, - { - id: 'minx', - scrambler: 'minx', - name: 'Megaminx', - group: 'WCA', - }, - { - id: 'pyram', - scrambler: 'pyram', - name: 'Pyraminx', - group: 'WCA', - }, - { - id: 'clock', - scrambler: 'clock', - name: 'Clock', - group: 'WCA', - }, - { - id: 'clock-optimal', - scrambler: 'clock-optimal', - name: 'Clock (Optimal)', - group: 'Other', - }, - { - id: 'skewb', - scrambler: 'skewb', - name: 'Skewb', - group: 'WCA', - }, - { - id: 'sq1', - scrambler: 'sq1', - name: 'Square-1', - group: 'WCA', - }, - { - id: '444bf', - scrambler: '444', - name: '4x4 Blindfolded', - group: 'WCA', - }, - { - id: '555bf', - scrambler: '555', - name: '5x5 Blindfolded', - group: 'WCA', - // }, { - // id: '333mbf', - // name: '3x3x3 Multi-Blind', - }, - { - id: 'fto', - scrambler: 'fto', - name: 'FTO', - group: 'Other', - }, - { - id: 'pll', - scrambler: 'pll', - name: 'PLL', - group: 'Other', - }, - { - id: 'zbll', - scrambler: 'zbll', - name: 'ZBLL', - group: 'Other', - }, - { - id: 'lse', - scrambler: 'lse', - name: 'Last Six Edges', - group: 'Other', - }, - { - id: 'ru', - scrambler: 'ru', - name: 'RU 2gen', - group: 'Other', - }, -]; +import Events from './events.json'; -module.exports.Events = Events; +export { Events }; -module.exports.getNameFromId = (eventId) => Events.find((e) => e.id === eventId).name; +export const getNameFromId = (eventId) => Events.find((event) => event.id === eventId).name; diff --git a/client/src/lib/events.json b/client/src/lib/events.json new file mode 100644 index 0000000..78cbcf5 --- /dev/null +++ b/client/src/lib/events.json @@ -0,0 +1,134 @@ +[ + { + "id": "333", + "scrambler": "333", + "name": "3x3", + "group": "WCA" + }, + { + "id": "222", + "scrambler": "222", + "name": "2x2", + "group": "WCA" + }, + { + "id": "444", + "scrambler": "444", + "name": "4x4", + "group": "WCA" + }, + { + "id": "555", + "scrambler": "555", + "name": "5x5", + "group": "WCA" + }, + { + "id": "666", + "scrambler": "666", + "name": "6x6", + "group": "WCA" + }, + { + "id": "777", + "scrambler": "777", + "name": "7x7", + "group": "WCA" + }, + { + "id": "333bf", + "scrambler": "333", + "name": "3x3 Blindfolded", + "group": "WCA" + }, + { + "id": "333oh", + "scrambler": "333", + "name": "3x3 One-Handed", + "group": "WCA" + }, + { + "id": "333ft", + "scrambler": "333", + "name": "3x3 With Feet", + "group": "WCA" + }, + { + "id": "minx", + "scrambler": "minx", + "name": "Megaminx", + "group": "WCA" + }, + { + "id": "pyram", + "scrambler": "pyram", + "name": "Pyraminx", + "group": "WCA" + }, + { + "id": "clock", + "scrambler": "clock", + "name": "Clock", + "group": "WCA" + }, + { + "id": "clock-optimal", + "scrambler": "clock-optimal", + "name": "Clock (Optimal)", + "group": "Other" + }, + { + "id": "skewb", + "scrambler": "skewb", + "name": "Skewb", + "group": "WCA" + }, + { + "id": "sq1", + "scrambler": "sq1", + "name": "Square-1", + "group": "WCA" + }, + { + "id": "444bf", + "scrambler": "444", + "name": "4x4 Blindfolded", + "group": "WCA" + }, + { + "id": "555bf", + "scrambler": "555", + "name": "5x5 Blindfolded", + "group": "WCA" + }, + { + "id": "fto", + "scrambler": "fto", + "name": "FTO", + "group": "Other" + }, + { + "id": "pll", + "scrambler": "pll", + "name": "PLL", + "group": "Other" + }, + { + "id": "zbll", + "scrambler": "zbll", + "name": "ZBLL", + "group": "Other" + }, + { + "id": "lse", + "scrambler": "lse", + "name": "Last Six Edges", + "group": "Other" + }, + { + "id": "ru", + "scrambler": "ru", + "name": "RU 2gen", + "group": "Other" + } +] diff --git a/client/src/lib/protocol.js b/client/src/lib/protocol.js index 29b44ae..b224a35 100644 --- a/client/src/lib/protocol.js +++ b/client/src/lib/protocol.js @@ -1,49 +1,3 @@ -// socket.io event names -module.exports = { - MESSAGE: 'message', - CONNECT: 'connect', - DISCONNECT: 'disconnect', - CONNECT_ERR: 'connect_error', - RECONNECT_ERR: 'reconnect_error', - RECONNECT: 'reconnect', - ERROR: 'errorrr', - UPDATE_ROOMS: 'update_rooms', - UPDATE_ROOM: 'update_room', - GLOBAL_ROOM_UPDATED: 'global_room_updated', - CREATE_ROOM: 'create_room', - DELETE_ROOM: 'delete_room', - ROOM_CREATED: 'room_created', - ROOM_DELETED: 'room_deleted', - UPDATE_ADMIN: 'update_admin', - FORCE_JOIN: 'force_join', - FORCE_LEAVE: 'force_leave', - KICKED: 'kicked', - BANNED: 'banned', - JOIN_ROOM: 'join_room', - LEAVE_ROOM: 'leave_room', - JOIN: 'join', - USER_JOIN: 'user_join', - USER_LEFT: 'user_left', - SUBMIT_RESULT: 'submit_result', - NEW_RESULT: 'new_result', - SEND_EDIT_RESULT: 'send_edit_result', - EDIT_RESULT: 'edit_result', - NEW_ATTEMPT: 'new_attempt', - REQUEST_SCRAMBLE: 'request_scramble', - CHANGE_EVENT: 'change_event', - EDIT_ROOM: 'edit_room', - UPDATE_PREFERENCES: 'update_preferences', - UPDATE_STATUS: 'update_status', - UPDATE_COMPETING: 'update_competing', - UPDATE_USER_COUNT: 'user_count', - UPDATE_USERS_IN_LOBBY: 'update_users_in_lobby', - KICK_USER: 'kick_user', - BAN_USER: 'ban_user', - UNBAN_USER: 'unban_user', - UPDATE_REGISTRATION: 'register', - NEXT_SOLVE_AT: 'next_solve_at', // transmits an approx time to expect the new scramble - START_ROOM: 'start', - PAUSE_ROOM: 'pause', - UPDATE_USER: 'update_user', - ADMIN: 'admin', -}; +import Protocol from './protocol.json'; + +export default Protocol; diff --git a/client/src/lib/protocol.json b/client/src/lib/protocol.json new file mode 100644 index 0000000..8448c6f --- /dev/null +++ b/client/src/lib/protocol.json @@ -0,0 +1,48 @@ +{ + "MESSAGE": "message", + "CONNECT": "connect", + "DISCONNECT": "disconnect", + "CONNECT_ERR": "connect_error", + "RECONNECT_ERR": "reconnect_error", + "RECONNECT": "reconnect", + "ERROR": "errorrr", + "UPDATE_ROOMS": "update_rooms", + "UPDATE_ROOM": "update_room", + "GLOBAL_ROOM_UPDATED": "global_room_updated", + "CREATE_ROOM": "create_room", + "DELETE_ROOM": "delete_room", + "ROOM_CREATED": "room_created", + "ROOM_DELETED": "room_deleted", + "UPDATE_ADMIN": "update_admin", + "FORCE_JOIN": "force_join", + "FORCE_LEAVE": "force_leave", + "KICKED": "kicked", + "BANNED": "banned", + "JOIN_ROOM": "join_room", + "LEAVE_ROOM": "leave_room", + "JOIN": "join", + "USER_JOIN": "user_join", + "USER_LEFT": "user_left", + "SUBMIT_RESULT": "submit_result", + "NEW_RESULT": "new_result", + "SEND_EDIT_RESULT": "send_edit_result", + "EDIT_RESULT": "edit_result", + "NEW_ATTEMPT": "new_attempt", + "REQUEST_SCRAMBLE": "request_scramble", + "CHANGE_EVENT": "change_event", + "EDIT_ROOM": "edit_room", + "UPDATE_PREFERENCES": "update_preferences", + "UPDATE_STATUS": "update_status", + "UPDATE_COMPETING": "update_competing", + "UPDATE_USER_COUNT": "user_count", + "UPDATE_USERS_IN_LOBBY": "update_users_in_lobby", + "KICK_USER": "kick_user", + "BAN_USER": "ban_user", + "UNBAN_USER": "unban_user", + "UPDATE_REGISTRATION": "register", + "NEXT_SOLVE_AT": "next_solve_at", + "START_ROOM": "start", + "PAUSE_ROOM": "pause", + "UPDATE_USER": "update_user", + "ADMIN": "admin" +} diff --git a/client/src/store/index.js b/client/src/store/index.js index 5284e02..5f4bcee 100644 --- a/client/src/store/index.js +++ b/client/src/store/index.js @@ -33,7 +33,8 @@ const trackPage = (page) => { const gaTrackingMiddleware = () => (next) => (action) => { if (action.type === '@@router/LOCATION_CHANGE') { - const nextPage = `${action.payload.location.pathname}${action.payload.location.search}`; + // Query strings can contain the temporary WCA OAuth authorization code. + const nextPage = action.payload.location.pathname; trackPage(nextPage); } return next(action); diff --git a/client/src/store/middlewares/default.js b/client/src/store/middlewares/default.js index ae4d7c7..366c97c 100644 --- a/client/src/store/middlewares/default.js +++ b/client/src/store/middlewares/default.js @@ -1,4 +1,4 @@ -import * as Protocol from '../../lib/protocol'; +import Protocol from '../../lib/protocol'; import Namespace from '../../lib/Namespace'; import { CONNECT, diff --git a/client/src/store/middlewares/rooms.js b/client/src/store/middlewares/rooms.js index 7155296..c0e621e 100644 --- a/client/src/store/middlewares/rooms.js +++ b/client/src/store/middlewares/rooms.js @@ -1,7 +1,7 @@ import { push } from 'connected-react-router'; import UIfx from 'uifx'; import notificationAsset from '../../assets/notification.mp3'; -import * as Protocol from '../../lib/protocol'; +import Protocol from '../../lib/protocol'; import Namespace from '../../lib/Namespace'; import { uuid } from '../../lib/utils'; import { diff --git a/client/src/theme.jsx b/client/src/theme.jsx index aeae1d0..f932545 100644 --- a/client/src/theme.jsx +++ b/client/src/theme.jsx @@ -7,8 +7,8 @@ import { } from '@material-ui/core/colors'; import { createMuiTheme, - ThemeProvider as MuiThemeProvider, } from '@material-ui/core/styles'; +import MuiThemeProvider from '@material-ui/styles/ThemeProvider'; import useMediaQuery from '@material-ui/core/useMediaQuery'; const defaultTheme = createMuiTheme(); diff --git a/compose.dev.yml b/compose.dev.yml index 6ced287..a1689a4 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -1,4 +1,8 @@ services: + migrate: + profiles: + - migrations + client: image: node:22-bookworm working_dir: /app diff --git a/compose.prod.yml b/compose.prod.yml index 687a6b3..a76a6bb 100644 --- a/compose.prod.yml +++ b/compose.prod.yml @@ -7,6 +7,9 @@ x-app-build: &app-build REACT_APP_WCA_CLIENT_ID: ${REACT_APP_WCA_CLIENT_ID:-} services: + migrate: + build: *app-build + api: build: *app-build restart: unless-stopped diff --git a/compose.yml b/compose.yml index 72ceca4..574303d 100644 --- a/compose.yml +++ b/compose.yml @@ -6,6 +6,17 @@ x-app-environment: &app-environment SOCKETIO_PORT: ${SOCKETIO_PORT:-9000} MONGO_URL: ${MONGO_URL:-mongodb://mongo:27017/letscube} REDIS_URL: ${REDIS_URL:-redis://redis:6379} + POSTGRES_ENABLED: ${POSTGRES_ENABLED:-true} + DATABASE_URL: ${DATABASE_URL:-} + DIRECT_DATABASE_URL: ${DIRECT_DATABASE_URL:-} + PGHOST: ${PGHOST:-postgres} + PGPORT: ${PGPORT:-5432} + PGDATABASE: ${PGDATABASE:-letscube} + PGUSER: ${PGUSER:-letscube} + PGPASSWORD: ${POSTGRES_PASSWORD:-change-me} + PGSSL: ${PGSSL:-false} + PGSSL_REJECT_UNAUTHORIZED: ${PGSSL_REJECT_UNAUTHORIZED:-true} + PGSSL_CA: ${PGSSL_CA:-} AUTH_SECRET: ${AUTH_SECRET:-change-me} SESSION_SECRET: ${SESSION_SECRET:-} AUTH_CALLBACK_URL: ${AUTH_CALLBACK_URL:-https://letscube.net/auth/callback} @@ -15,6 +26,16 @@ x-app-environment: &app-environment CORS_ORIGINS: ${CORS_ORIGINS:-https://letscube.net,https://www.letscube.net} services: + migrate: + image: letscube-app:${APP_IMAGE_TAG:-local} + command: ["./node_modules/.bin/prisma", "migrate", "deploy", "--config", "server/prisma.config.mjs"] + environment: + <<: *app-environment + depends_on: + postgres: + condition: service_healthy + restart: "no" + api: image: letscube-app:${APP_IMAGE_TAG:-local} command: ["node", "server/index.js"] @@ -64,6 +85,21 @@ services: retries: 5 start_period: 30s + postgres: + image: postgres:17-alpine + environment: + POSTGRES_DB: ${PGDATABASE:-letscube} + POSTGRES_USER: ${PGUSER:-letscube} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me} + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${PGUSER:-letscube} -d ${PGDATABASE:-letscube}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + redis: image: redis:7.2-alpine command: ["redis-server", "--appendonly", "yes"] @@ -77,4 +113,5 @@ services: volumes: mongo-data: + postgres-data: redis-data: diff --git a/docker-compose.yml b/docker-compose.yml index 01224e2..8acefc8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,21 @@ services: + postgres: + image: postgres:17-alpine + ports: + - "127.0.0.1:55433:5432" + environment: + POSTGRES_DB: letscube + POSTGRES_USER: letscube + POSTGRES_PASSWORD: letscube + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U letscube -d letscube"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + mongo: image: mongo:6.0 ports: @@ -23,4 +40,5 @@ services: volumes: mongo-data: + postgres-data: redis-data: diff --git a/package.json b/package.json index f94b7f5..5790db9 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "server" ], "resolutions": { + "@hono/node-server": "1.19.13", "cheerio": "1.0.0-rc.12" }, "scripts": { diff --git a/server/auth/index.js b/server/auth/index.js index e2613d9..91219c9 100644 --- a/server/auth/index.js +++ b/server/auth/index.js @@ -2,6 +2,7 @@ const express = require('express'); const CustomStrategy = require('passport-custom').Strategy; const { URLSearchParams } = require('url'); const { User } = require('../models'); +const metrics = require('../metrics'); const checkStatus = async (res) => { if (res.ok) { // res.status >= 200 && res.status < 300 @@ -20,10 +21,11 @@ module.exports = (app, passport) => { const { code, redirectUri } = req.body; if (!code) { - done(new Error({ - status: 400, - message: 'Invalid code passed', - })); + const err = new Error('Invalid code passed'); + Object.defineProperty(err, 'authFailureReason', { + value: 'missing_code', + }); + done(err); return; } @@ -118,19 +120,24 @@ module.exports = (app, passport) => { router.post('/code', (req, res, next) => { - passport.authenticate('custom', (err, user) => { + passport.authenticate('custom', async (err, user) => { if (err) { + await metrics.recordAuthFailure( + err.authFailureReason || 'provider_or_persistence_error', + ); return res.status(500).json(err); } if (!user) { + await metrics.recordAuthFailure('no_user'); return res.status(401).json({ message: 'Authentication failed', }); } - req.login(user, (loginErr) => { + req.login(user, async (loginErr) => { if (loginErr) { + await metrics.recordAuthFailure('session_login_error'); return res.json(loginErr); } diff --git a/server/config/default.json b/server/config/default.json index e4a27b9..8c3bbd8 100644 --- a/server/config/default.json +++ b/server/config/default.json @@ -6,6 +6,13 @@ "port": 9000 }, "mongodb": "mongodb://127.0.0.1/letscube", + "postgres": { + "host": "127.0.0.1", + "port": 5432, + "database": "letscube", + "user": "letscube", + "password": "letscube" + }, "wcaSource": "https://staging.worldcubeassociation.org", "auth": { "secret": "", diff --git a/server/config/dev.json b/server/config/dev.json index 18ea264..115e325 100644 --- a/server/config/dev.json +++ b/server/config/dev.json @@ -7,6 +7,13 @@ "port": 9000 }, "mongodb": "mongodb://127.0.0.1/letscube", + "postgres": { + "host": "127.0.0.1", + "port": 55433, + "database": "letscube", + "user": "letscube", + "password": "letscube" + }, "wcaSource": "https://staging.worldcubeassociation.org", "auth": { "secret": "dev-secret", @@ -17,4 +24,4 @@ "cors": { "origin": ["localhost", "192.168.1.18"] } -} \ No newline at end of file +} diff --git a/server/index.js b/server/index.js index 4a85e6e..3293ac0 100644 --- a/server/index.js +++ b/server/index.js @@ -7,6 +7,7 @@ const passport = require('passport'); const config = require('./runtimeConfig'); const { connect } = require('./database'); +const { initializePostgres, startPostgresMaintenance } = require('./postgres'); const session = require('./middlewares/session'); const logger = require('./logger'); const auth = require('./auth'); @@ -25,6 +26,8 @@ const init = async () => { app.use(express.urlencoded({ extended: true })); // for parsing application/x-www-form-urlencoded const mongoose = await connect(); + await initializePostgres(); + startPostgresMaintenance(); /* Logging */ diff --git a/server/metrics.js b/server/metrics.js new file mode 100644 index 0000000..f1fb7ce --- /dev/null +++ b/server/metrics.js @@ -0,0 +1,227 @@ +const crypto = require('crypto'); +const { v4: uuidv4 } = require('uuid'); + +const config = require('./runtimeConfig'); +const logger = require('./logger'); +const { METRIC_EVENTS, MetricEvent } = require('./models'); +const { mirrorMetricEvent } = require('./postgres/dualWrite'); + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +const countRoomSolves = (room) => room.attempts.reduce((total, attempt) => { + const resultCount = attempt.results instanceof Map + ? attempt.results.size + : Object.keys(attempt.results || {}).length; + return total + resultCount; +}, 0); + +const createMetricRecorder = ({ + model = MetricEvent, + metricsConfig = config.metrics, + metricLogger = logger, + metricMirror = mirrorMetricEvent, + now = () => new Date(), +} = {}) => { + const enabled = !!metricsConfig.enabled; + const retentionMs = metricsConfig.retentionDays * MS_PER_DAY; + + const pseudonymize = (kind, value) => { + if (value === undefined || value === null || !metricsConfig.hashSecret) { + return undefined; + } + + return crypto.createHmac('sha256', metricsConfig.hashSecret) + .update(`${kind}:${value}`) + .digest('hex'); + }; + + const roomProperties = (room) => { + if (!room) { + return {}; + } + + return { + roomId: pseudonymize('room', room._id || room.id), + roomType: room.type, + cubeEvent: room.event, + privateRoom: !!room.password, + }; + }; + + const actorProperties = ({ userId, connectionId }) => ({ + actorId: pseudonymize(userId ? 'user' : 'connection', userId || connectionId), + actorType: userId ? 'authenticated' : 'anonymous', + }); + + const write = async (event, { ignoreDuplicate = false } = {}) => { + if (!enabled) { + return null; + } + + const occurredAt = now(); + const persistedEvent = { + ...event, + eventId: uuidv4(), + occurredAt, + expiresAt: new Date(occurredAt.getTime() + retentionMs), + }; + let created = null; + try { + created = await model.create(persistedEvent); + } catch (err) { + if (!ignoreDuplicate || err.code !== 11000) { + metricLogger.error(err); + } + if (ignoreDuplicate && err.code === 11000) { + return null; + } + } + + Promise.resolve() + .then(() => metricMirror(persistedEvent)) + .catch((err) => metricLogger.error(err)); + return created; + }; + + const beginRoomVisit = async ({ + room, userId, connectionId, activeUserCount, replaceActive = false, + }) => { + if (!enabled) { + return null; + } + + const event = { + event: METRIC_EVENTS.ROOM_JOINED, + ...actorProperties({ userId, connectionId }), + ...roomProperties(room), + activeUserCount, + active: true, + }; + + if (replaceActive && event.actorId && event.roomId) { + try { + await model.updateMany({ + event: METRIC_EVENTS.ROOM_JOINED, + actorId: event.actorId, + roomId: event.roomId, + active: true, + }, { + $set: { + active: false, + closedAt: now(), + }, + }); + } catch (err) { + metricLogger.error(err); + } + } + + // An authenticated user may join through multiple tabs. The partial unique + // index turns those sockets into one logical room visit. + const created = await write(event, { ignoreDuplicate: true }); + if (created || !event.actorId || !event.roomId) { + return created; + } + + try { + return await model.findOne({ + event: METRIC_EVENTS.ROOM_JOINED, + actorId: event.actorId, + roomId: event.roomId, + active: true, + }); + } catch (err) { + metricLogger.error(err); + return null; + } + }; + + const endRoomVisit = async ({ + room, userId, connectionId, leaveReason, activeUserCount, + }) => { + if (!enabled) { + return null; + } + + const actor = actorProperties({ userId, connectionId }); + const roomData = roomProperties(room); + const closedAt = now(); + let durationMs; + let visitFound = false; + + if (actor.actorId && roomData.roomId) { + try { + const visit = await model.findOneAndUpdate({ + event: METRIC_EVENTS.ROOM_JOINED, + actorId: actor.actorId, + roomId: roomData.roomId, + active: true, + }, { + $set: { + active: false, + closedAt, + }, + }, { + sort: { occurredAt: -1 }, + }); + + if (visit) { + visitFound = true; + durationMs = Math.max(0, closedAt.getTime() - visit.occurredAt.getTime()); + } + } catch (err) { + metricLogger.error(err); + } + } + + // A kick or ban is followed by the affected socket disconnecting. Only the + // first path that closes the active visit should emit a leave event. + if (actor.actorId && roomData.roomId && !visitFound) { + return null; + } + + return write({ + event: METRIC_EVENTS.ROOM_LEFT, + ...actor, + ...roomData, + leaveReason, + activeUserCount, + durationMs, + }); + }; + + return { + recordAuthFailure: (failureReason) => write({ + event: METRIC_EVENTS.AUTH_FAILED, + failureReason, + }), + recordRoomCreated: ({ room, userId }) => write({ + event: METRIC_EVENTS.ROOM_CREATED, + ...actorProperties({ userId }), + ...roomProperties(room), + }), + recordRoomJoinFailure: ({ + room, userId, connectionId, failureReason, + }) => write({ + event: METRIC_EVENTS.ROOM_JOIN_FAILED, + ...actorProperties({ userId, connectionId }), + ...roomProperties(room), + failureReason, + }), + recordRoomResult: ({ room, userId }) => write({ + event: METRIC_EVENTS.ROOM_RESULT_SUBMITTED, + ...actorProperties({ userId }), + ...roomProperties(room), + roomSolveCount: countRoomSolves(room), + }), + beginRoomVisit, + endRoomVisit, + pseudonymize, + }; +}; + +module.exports = { + ...createMetricRecorder(), + countRoomSolves, + createMetricRecorder, +}; diff --git a/server/metrics.test.js b/server/metrics.test.js new file mode 100644 index 0000000..c380e1b --- /dev/null +++ b/server/metrics.test.js @@ -0,0 +1,192 @@ +/** @jest-environment node */ +/* eslint-env jest */ + +const { METRIC_EVENTS } = require('./models/metricEvent'); +const { countRoomSolves, createMetricRecorder } = require('./metrics'); + +const room = { + _id: 'room-123', + type: 'normal', + event: '333', + password: 'hashed-password', + attempts: [ + { results: new Map([['1', {}], ['2', {}]]) }, + { results: new Map([['1', {}]]) }, + ], +}; + +const createRecorder = (overrides = {}) => { + const model = { + create: jest.fn(async (event) => event), + findOne: jest.fn(), + findOneAndUpdate: jest.fn(), + updateMany: jest.fn(), + }; + const metricLogger = { error: jest.fn() }; + const metricMirror = jest.fn(); + const now = jest.fn(() => new Date('2026-07-09T12:00:00.000Z')); + const recorder = createMetricRecorder({ + model, + metricLogger, + metricMirror, + now, + metricsConfig: { + enabled: true, + hashSecret: 'metrics-test-secret', + retentionDays: 90, + }, + ...overrides, + }); + + return { + model, metricLogger, metricMirror, now, recorder, + }; +}; + +describe('metrics recorder', () => { + it('pseudonymizes users and rooms with separated identifiers', () => { + const { recorder } = createRecorder(); + + const userId = recorder.pseudonymize('user', 123); + const roomId = recorder.pseudonymize('room', 123); + + expect(userId).toHaveLength(64); + expect(userId).not.toBe('123'); + expect(userId).not.toBe(roomId); + expect(recorder.pseudonymize('user', 123)).toBe(userId); + }); + + it('records one active room visit without raw identifiers or room names', async () => { + const { model, recorder } = createRecorder(); + + await recorder.beginRoomVisit({ + room, + userId: 123, + connectionId: 'socket-secret', + activeUserCount: 4, + }); + + expect(model.create).toHaveBeenCalledWith(expect.objectContaining({ + event: METRIC_EVENTS.ROOM_JOINED, + actorType: 'authenticated', + activeUserCount: 4, + roomType: 'normal', + cubeEvent: '333', + privateRoom: true, + active: true, + occurredAt: new Date('2026-07-09T12:00:00.000Z'), + expiresAt: new Date('2026-10-07T12:00:00.000Z'), + })); + + const event = model.create.mock.calls[0][0]; + expect(event.actorId).not.toContain('123'); + expect(event.roomId).not.toContain('room-123'); + expect(event).not.toHaveProperty('connectionId'); + expect(event).not.toHaveProperty('name'); + expect(event).not.toHaveProperty('password'); + }); + + it('closes a stale visit when the room state says this is a new join', async () => { + const { model, recorder } = createRecorder(); + + await recorder.beginRoomVisit({ + room, + userId: 123, + activeUserCount: 4, + replaceActive: true, + }); + + expect(model.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + event: METRIC_EVENTS.ROOM_JOINED, + active: true, + }), + { $set: expect.objectContaining({ active: false }) }, + ); + expect(model.create).toHaveBeenCalledTimes(1); + }); + + it('records a leave with the duration of the open visit', async () => { + const joinedAt = new Date('2026-07-09T11:55:00.000Z'); + const { model, recorder } = createRecorder(); + model.findOneAndUpdate.mockResolvedValue({ occurredAt: joinedAt }); + + await recorder.endRoomVisit({ + room, + userId: 123, + leaveReason: 'explicit', + activeUserCount: 3, + }); + + expect(model.findOneAndUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + event: METRIC_EVENTS.ROOM_JOINED, + active: true, + }), + { $set: expect.objectContaining({ active: false }) }, + { sort: { occurredAt: -1 } }, + ); + expect(model.create).toHaveBeenCalledWith(expect.objectContaining({ + event: METRIC_EVENTS.ROOM_LEFT, + leaveReason: 'explicit', + durationMs: 5 * 60 * 1000, + activeUserCount: 3, + })); + }); + + it('does not duplicate a leave after a visit has already closed', async () => { + const { model, recorder } = createRecorder(); + model.findOneAndUpdate.mockResolvedValue(null); + + await expect(recorder.endRoomVisit({ + room, + userId: 123, + leaveReason: 'disconnect', + activeUserCount: 3, + })).resolves.toBeNull(); + + expect(model.create).not.toHaveBeenCalled(); + }); + + it('records join and authentication failure categories only', async () => { + const { model, recorder } = createRecorder(); + + await recorder.recordRoomJoinFailure({ + room, + userId: 123, + failureReason: 'invalid_password', + }); + await recorder.recordAuthFailure('missing_code'); + + expect(model.create).toHaveBeenNthCalledWith(1, expect.objectContaining({ + event: METRIC_EVENTS.ROOM_JOIN_FAILED, + failureReason: 'invalid_password', + })); + expect(model.create).toHaveBeenNthCalledWith(2, expect.objectContaining({ + event: METRIC_EVENTS.AUTH_FAILED, + failureReason: 'missing_code', + })); + }); + + it('counts unique result slots without storing solve times', async () => { + const { model, recorder } = createRecorder(); + + expect(countRoomSolves(room)).toBe(3); + await recorder.recordRoomResult({ room, userId: 123 }); + + expect(model.create).toHaveBeenCalledWith(expect.objectContaining({ + event: METRIC_EVENTS.ROOM_RESULT_SUBMITTED, + roomSolveCount: 3, + })); + expect(model.create.mock.calls[0][0]).not.toHaveProperty('attempts'); + }); + + it('does not disrupt application behavior when metrics storage fails', async () => { + const { model, metricLogger, recorder } = createRecorder(); + const error = new Error('metrics database unavailable'); + model.create.mockRejectedValue(error); + + await expect(recorder.recordAuthFailure('no_user')).resolves.toBeNull(); + expect(metricLogger.error).toHaveBeenCalledWith(error); + }); +}); diff --git a/server/models/index.js b/server/models/index.js index 86f1922..8e5ba12 100644 --- a/server/models/index.js +++ b/server/models/index.js @@ -1,9 +1,12 @@ const mongoose = require('mongoose'); const { Attempt, Room } = require('./room'); +const { METRIC_EVENTS, MetricEvent } = require('./metricEvent'); const User = require('./user'); module.exports = { Room: mongoose.model('Room', Room, 'rooms'), User: mongoose.model('User', User, 'users'), Attempt: mongoose.model('Attempt', Attempt, 'attempts'), + MetricEvent: mongoose.model('MetricEvent', MetricEvent, 'metric_events'), + METRIC_EVENTS, }; diff --git a/server/models/metricEvent.js b/server/models/metricEvent.js new file mode 100644 index 0000000..56b0a5b --- /dev/null +++ b/server/models/metricEvent.js @@ -0,0 +1,112 @@ +const mongoose = require('mongoose'); +const { v4: uuidv4 } = require('uuid'); + +const METRIC_EVENTS = Object.freeze({ + AUTH_FAILED: 'auth_failed', + ROOM_CREATED: 'room_created', + ROOM_JOINED: 'room_joined', + ROOM_JOIN_FAILED: 'room_join_failed', + ROOM_LEFT: 'room_left', + ROOM_RESULT_SUBMITTED: 'room_result_submitted', +}); + +const MetricEvent = new mongoose.Schema({ + eventId: { + type: String, + default: uuidv4, + required: true, + }, + event: { + type: String, + enum: Object.values(METRIC_EVENTS), + required: true, + index: true, + }, + actorId: { + type: String, + index: true, + }, + actorType: { + type: String, + enum: ['authenticated', 'anonymous'], + }, + roomId: { + type: String, + index: true, + }, + roomType: { + type: String, + enum: ['normal', 'grand_prix'], + }, + cubeEvent: String, + privateRoom: Boolean, + failureReason: String, + leaveReason: { + type: String, + enum: ['disconnect', 'explicit', 'kick', 'ban'], + }, + activeUserCount: { + type: Number, + min: 0, + }, + roomSolveCount: { + type: Number, + min: 0, + }, + durationMs: { + type: Number, + min: 0, + }, + active: { + type: Boolean, + default: false, + }, + closedAt: Date, + occurredAt: { + type: Date, + default: Date.now, + required: true, + index: true, + }, + expiresAt: { + type: Date, + required: true, + }, +}, { + versionKey: false, +}); + +MetricEvent.index({ + expiresAt: 1, +}, { + expireAfterSeconds: 0, +}); + +MetricEvent.index({ + eventId: 1, +}, { + unique: true, + partialFilterExpression: { + eventId: { $type: 'string' }, + }, +}); + +// Prevent multiple browser tabs from opening overlapping visits for one user. +MetricEvent.index({ + actorId: 1, + roomId: 1, + event: 1, +}, { + unique: true, + partialFilterExpression: { + event: METRIC_EVENTS.ROOM_JOINED, + active: true, + actorId: { $type: 'string' }, + roomId: { $type: 'string' }, + }, +}); + +module.exports = { + METRIC_EVENTS, + MetricEvent, +}; diff --git a/server/models/room.js b/server/models/room.js index 322d22a..d9cb67f 100644 --- a/server/models/room.js +++ b/server/models/room.js @@ -2,7 +2,8 @@ const mongoose = require('mongoose'); const { Scrambow } = require('scrambow'); const bcrypt = require('bcrypt'); const { v4: uuidv4 } = require('uuid'); -const { Events } = require('../../client/src/lib/events'); +const Events = require('../../client/src/lib/events.json'); +const { mirrorRoomChanges } = require('../postgres/dualWrite'); const PASSWORD_SALT_ROUNDS = 10; const STALE_ROOM_LIFETIME_MS = 10 * 60 * 1000; @@ -326,5 +327,77 @@ Room.methods.updateAdminIfNeeded = function (cb) { } }; +const collectPostgresChanges = (room) => { + const changesByAttempt = new Map(); + const participantUserIds = new Set(); + const ensureAttempt = (attemptIndex) => { + if (!changesByAttempt.has(attemptIndex)) { + changesByAttempt.set(attemptIndex, { + attemptIndex, + resultUserIds: new Set(), + resultsMapModified: false, + syncAllResults: false, + }); + } + return changesByAttempt.get(attemptIndex); + }; + + room.modifiedPaths({ includeChildren: true }).forEach((path) => { + const participantMatch = path.match( + /^(?:competing|waitingFor|banned|inRoom|registered)\.([^.]+)/, + ); + if (participantMatch) { + participantUserIds.add(participantMatch[1]); + } + + const resultMatch = path.match(/^attempts\.(\d+)\.results(?:\.([^.]+))?/); + if (resultMatch) { + const change = ensureAttempt(Number(resultMatch[1])); + if (resultMatch[2]) { + change.resultUserIds.add(resultMatch[2]); + } else { + change.resultsMapModified = true; + } + return; + } + + const attemptMatch = path.match(/^attempts\.(\d+)\.(?:id|scrambles)(?:\.|$)/); + if (attemptMatch) { + ensureAttempt(Number(attemptMatch[1])); + } + }); + + (room.attempts || []).forEach((attempt, attemptIndex) => { + if (attempt.isNew) { + ensureAttempt(attemptIndex).syncAllResults = true; + } + }); + + const attempts = [...changesByAttempt.values()].map((change) => ({ + attemptIndex: change.attemptIndex, + resultUserIds: [...change.resultUserIds], + syncAllResults: change.syncAllResults + || (change.resultsMapModified && change.resultUserIds.size === 0), + })); + + return { + attempts, + participantUserIds: [...participantUserIds], + replaceAttempts: !room.isNew && room.isModified('event'), + syncAllParticipants: room.isNew, + syncRoomOwners: room.isNew || room.isModified('owner') || room.isModified('admin'), + }; +}; + +Room.pre('save', function capturePostgresChanges() { + this.$locals.postgresChanges = collectPostgresChanges(this); +}); + +Room.post('save', async (room) => { + // PostgreSQL failures are logged and swallowed by the migration-phase writer. + await mirrorRoomChanges(room, room.$locals.postgresChanges); +}); + module.exports.Attempt = Attempt; +module.exports.collectPostgresChanges = collectPostgresChanges; module.exports.Room = Room; diff --git a/server/models/room.test.js b/server/models/room.test.js index cd844ca..a28039a 100644 --- a/server/models/room.test.js +++ b/server/models/room.test.js @@ -2,7 +2,10 @@ /* eslint-env jest */ const bcrypt = require('bcrypt'); -const { Room } = require('./room'); +const mongoose = require('mongoose'); +const { collectPostgresChanges, Room } = require('./room'); + +const RoomModel = mongoose.model('RoomPostgresChangesTest', Room); describe('room security helpers', () => { it('authenticates passwords asynchronously', async () => { @@ -48,4 +51,74 @@ describe('room security helpers', () => { expect(room.expireAt.getTime()).toBeGreaterThanOrEqual(before + 10 * 60 * 1000); expect(room.expireAt.getTime()).toBeLessThanOrEqual(Date.now() + 10 * 60 * 1000); }); + + it('collects only the changed result for incremental PostgreSQL writes', () => { + const room = RoomModel.hydrate({ + _id: '507f1f77bcf86cd799439011', + name: 'Practice room', + event: '333', + waitingFor: { 1234: true }, + attempts: [{ + _id: '507f1f77bcf86cd799439012', + id: 0, + scrambles: ['R U R\''], + results: { + 1234: { + _id: '507f1f77bcf86cd799439013', + time: 12000, + penalties: {}, + }, + }, + }], + }); + room.attempts[0].results.set('1234', { + time: 13000, + penalties: { AUF: true }, + }); + room.waitingFor.set('1234', false); + + expect(collectPostgresChanges(room)).toEqual({ + attempts: [{ + attemptIndex: 0, + resultUserIds: ['1234'], + syncAllResults: false, + }], + participantUserIds: ['1234'], + replaceAttempts: false, + syncAllParticipants: false, + syncRoomOwners: false, + }); + }); + + it('replaces PostgreSQL attempts when the room event resets them', () => { + const room = RoomModel.hydrate({ + _id: '507f1f77bcf86cd799439011', + name: 'Practice room', + event: '333', + attempts: [{ + _id: '507f1f77bcf86cd799439012', + id: 0, + scrambles: ['R U R\''], + results: {}, + }], + }); + room.event = '222'; + room.attempts = [{ + id: 0, + scrambles: ['R U2 R\''], + results: {}, + }]; + + expect(collectPostgresChanges(room)).toEqual({ + attempts: [{ + attemptIndex: 0, + resultUserIds: [], + syncAllResults: true, + }], + participantUserIds: [], + replaceAttempts: true, + syncAllParticipants: false, + syncRoomOwners: false, + }); + }); }); diff --git a/server/models/user.js b/server/models/user.js index e6a6ba4..eb01155 100644 --- a/server/models/user.js +++ b/server/models/user.js @@ -1,4 +1,5 @@ const mongoose = require('mongoose'); +const { mirrorUser } = require('../postgres/dualWrite'); const redactUser = (doc, ret) => { delete ret.email; @@ -83,4 +84,12 @@ schema.virtual('canJoinRoom').get(function () { return this.preferRealName || !!this.username; }); +const dualWriteUser = (user) => { + // PostgreSQL is a non-blocking secondary during the dual-write phase. + mirrorUser(user); +}; + +schema.post('save', dualWriteUser); +schema.post('findOneAndUpdate', dualWriteUser); + module.exports = schema; diff --git a/server/package.json b/server/package.json index 8ad5f12..e06ba46 100644 --- a/server/package.json +++ b/server/package.json @@ -20,6 +20,8 @@ "passport-local": "^1.0.0", "passport-oauth2": "^1.8.0", "passport-wca": "^1.0.3", + "pg": "8.22.0", + "prisma": "7.8.0", "rest-api-errors": "^1.2.1", "scrambow": "^1.8.1", "socket.io": "^4.8.3", @@ -31,6 +33,11 @@ "start:socket": "nodemon socket/", "lint": "eslint ./", "lint:fix": "eslint --fix ./", + "postgres:migrate": "prisma migrate deploy --config prisma.config.mjs", + "postgres:migrate:dev": "prisma migrate dev --config prisma.config.mjs", + "postgres:migrate:status": "prisma migrate status --config prisma.config.mjs", + "postgres:schema:check": "prisma migrate diff --exit-code --from-config-datasource --to-schema prisma/schema.prisma --config prisma.config.mjs", + "postgres:schema:validate": "prisma validate --config prisma.config.mjs", "test": "jest --passWithNoTests", "test:ci": "yarn test" }, diff --git a/server/postgres/dualWrite.js b/server/postgres/dualWrite.js new file mode 100644 index 0000000..7fea99b --- /dev/null +++ b/server/postgres/dualWrite.js @@ -0,0 +1,557 @@ +/* eslint-disable no-await-in-loop, no-continue, no-restricted-syntax */ +const { v4: uuidv4, v5: uuidv5 } = require('uuid'); + +const { query, withTransaction } = require('./index'); + +const DUAL_WRITE_NAMESPACE = uuidv5('https://letscube.net/postgres-dual-write', uuidv5.URL); + +const stableId = (kind, value) => uuidv5(`${kind}:${value}`, DUAL_WRITE_NAMESPACE); + +const numericUserId = (user) => { + const value = typeof user === 'number' || typeof user === 'string' ? user : user && user.id; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; +}; + +const sourceDate = (value, fallback = new Date()) => { + if (!value) { + return fallback; + } + return value instanceof Date ? value : new Date(value); +}; + +const mapValue = (map, key) => { + if (!map) { + return false; + } + if (typeof map.get === 'function') { + return !!map.get(key.toString()); + } + return !!map[key.toString()]; +}; + +const resultEntries = (results) => { + if (!results) { + return []; + } + if (typeof results.entries === 'function') { + return [...results.entries()]; + } + return Object.entries(results); +}; + +const resultValue = (results, userId) => { + if (!results) { + return undefined; + } + if (typeof results.get === 'function') { + return results.get(userId.toString()); + } + return results[userId.toString()]; +}; + +const upsertUser = async (client, user, fallbackUpdatedAt = new Date()) => { + const wcaUserId = numericUserId(user); + if (!wcaUserId || !user || !user.name) { + return null; + } + + const id = stableId('user', wcaUserId); + const updatedAt = sourceDate(user.updatedAt, fallbackUpdatedAt); + await client.query(` + INSERT INTO app.users ( + id, wca_user_id, email, name, username, wca_id, preferences, avatar, + source_created_at, source_updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (wca_user_id) DO UPDATE SET + email = EXCLUDED.email, + name = EXCLUDED.name, + username = EXCLUDED.username, + wca_id = EXCLUDED.wca_id, + preferences = EXCLUDED.preferences, + avatar = EXCLUDED.avatar, + source_updated_at = EXCLUDED.source_updated_at, + ingested_at = now() + WHERE app.users.source_updated_at < EXCLUDED.source_updated_at + OR ( + app.users.source_updated_at = EXCLUDED.source_updated_at + AND ROW( + app.users.email, + app.users.name, + app.users.username, + app.users.wca_id, + app.users.preferences, + app.users.avatar + ) IS DISTINCT FROM ROW( + EXCLUDED.email, + EXCLUDED.name, + EXCLUDED.username, + EXCLUDED.wca_id, + EXCLUDED.preferences, + EXCLUDED.avatar + ) + ) + `, [ + id, + wcaUserId, + user.email || null, + user.name, + user.username || null, + user.wcaId || null, + { + showWCAID: !!user.showWCAID, + preferRealName: !!user.preferRealName, + useInspection: !!user.useInspection, + timerType: user.timerType || 'spacebar', + muteTimer: !!user.muteTimer, + }, + user.avatar || {}, + user.createdAt || null, + updatedAt, + ]); + + return id; +}; + +const mirrorUser = (user) => withTransaction((client) => upsertUser(client, user)); + +const upsertRoomState = async (client, room, options = {}) => { + if (!room || !room._id) { + return null; + } + + const roomId = stableId('room', room._id.toString()); + const updatedAt = sourceDate(room.updatedAt); + const users = (room.users || []).filter((user) => numericUserId(user)); + const requestedUserIds = new Set((options.userIds || []).map(numericUserId).filter(Boolean)); + const requestedParticipantIds = new Set( + (options.participantUserIds || []).map(numericUserId).filter(Boolean), + ); + const participantUsers = options.syncAllParticipants + ? users + : users.filter((user) => requestedParticipantIds.has(numericUserId(user))); + const requestedUsers = options.syncAllParticipants + ? users + : users.filter((user) => ( + requestedUserIds.has(numericUserId(user)) + || requestedParticipantIds.has(numericUserId(user)) + )); + const roomUsers = options.syncRoomOwners || options.syncAllParticipants + ? [room.owner, room.admin] + : []; + const relatedUsers = new Map(); + [...roomUsers, ...requestedUsers].forEach((user) => { + const wcaUserId = numericUserId(user); + if (wcaUserId) { + relatedUsers.set(wcaUserId, user); + } + }); + const knownUserIds = new Set(); + + for (const user of relatedUsers.values()) { + const mirroredId = await upsertUser(client, user, updatedAt); + if (mirroredId) { + knownUserIds.add(numericUserId(user)); + } + } + + const ownerWcaId = numericUserId(room.owner); + const adminWcaId = numericUserId(room.admin); + const ownerId = ownerWcaId && knownUserIds.has(ownerWcaId) + ? stableId('user', ownerWcaId) : null; + const adminId = adminWcaId && knownUserIds.has(adminWcaId) + ? stableId('user', adminWcaId) : null; + const ownerKnown = room.owner === null || !!ownerId; + const adminKnown = room.admin === null || !!adminId; + + await client.query(` + INSERT INTO app.rooms ( + id, mongo_id, name, cube_event, access_code, password_hash, room_type, + owner_id, admin_id, require_revealed_identity, start_time, started, + next_solve_at, expires_at, twitch_channel, source_created_at, source_updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17 + ) + ON CONFLICT (mongo_id) DO UPDATE SET + name = EXCLUDED.name, + cube_event = EXCLUDED.cube_event, + access_code = EXCLUDED.access_code, + password_hash = EXCLUDED.password_hash, + room_type = EXCLUDED.room_type, + owner_id = CASE WHEN $18 THEN EXCLUDED.owner_id ELSE app.rooms.owner_id END, + admin_id = CASE WHEN $19 THEN EXCLUDED.admin_id ELSE app.rooms.admin_id END, + require_revealed_identity = EXCLUDED.require_revealed_identity, + start_time = EXCLUDED.start_time, + started = EXCLUDED.started, + next_solve_at = EXCLUDED.next_solve_at, + expires_at = EXCLUDED.expires_at, + twitch_channel = EXCLUDED.twitch_channel, + deleted_at = NULL, + source_updated_at = EXCLUDED.source_updated_at, + ingested_at = now() + WHERE app.rooms.source_updated_at < EXCLUDED.source_updated_at + OR ( + app.rooms.source_updated_at = EXCLUDED.source_updated_at + AND ROW( + app.rooms.name, + app.rooms.cube_event, + app.rooms.access_code, + app.rooms.password_hash, + app.rooms.room_type, + app.rooms.owner_id, + app.rooms.admin_id, + app.rooms.require_revealed_identity, + app.rooms.start_time, + app.rooms.started, + app.rooms.next_solve_at, + app.rooms.expires_at, + app.rooms.twitch_channel, + app.rooms.deleted_at + ) IS DISTINCT FROM ROW( + EXCLUDED.name, + EXCLUDED.cube_event, + EXCLUDED.access_code, + EXCLUDED.password_hash, + EXCLUDED.room_type, + CASE WHEN $18 THEN EXCLUDED.owner_id ELSE app.rooms.owner_id END, + CASE WHEN $19 THEN EXCLUDED.admin_id ELSE app.rooms.admin_id END, + EXCLUDED.require_revealed_identity, + EXCLUDED.start_time, + EXCLUDED.started, + EXCLUDED.next_solve_at, + EXCLUDED.expires_at, + EXCLUDED.twitch_channel, + NULL + ) + ) + `, [ + roomId, + room._id.toString(), + room.name, + room.event, + room.accessCode, + room.password || null, + room.type, + ownerId, + adminId, + !!room.requireRevealedIdentity, + room.startTime || null, + !!room.started, + room.nextSolveAt || null, + room.expireAt || null, + room.twitchChannel || null, + room.createdAt || null, + updatedAt, + ownerKnown, + adminKnown, + ]); + + for (const user of participantUsers) { + const wcaUserId = numericUserId(user); + if (!knownUserIds.has(wcaUserId)) { + continue; + } + + await client.query(` + INSERT INTO app.room_participants ( + room_id, user_id, competing, waiting_for, banned, in_room, registered, + source_updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (room_id, user_id) DO UPDATE SET + competing = EXCLUDED.competing, + waiting_for = EXCLUDED.waiting_for, + banned = EXCLUDED.banned, + in_room = EXCLUDED.in_room, + registered = EXCLUDED.registered, + source_updated_at = EXCLUDED.source_updated_at, + ingested_at = now() + WHERE app.room_participants.source_updated_at < EXCLUDED.source_updated_at + OR ( + app.room_participants.source_updated_at = EXCLUDED.source_updated_at + AND ROW( + app.room_participants.competing, + app.room_participants.waiting_for, + app.room_participants.banned, + app.room_participants.in_room, + app.room_participants.registered + ) IS DISTINCT FROM ROW( + EXCLUDED.competing, + EXCLUDED.waiting_for, + EXCLUDED.banned, + EXCLUDED.in_room, + EXCLUDED.registered + ) + ) + `, [ + roomId, + stableId('user', wcaUserId), + mapValue(room.competing, wcaUserId), + mapValue(room.waitingFor, wcaUserId), + mapValue(room.banned, wcaUserId), + mapValue(room.inRoom, wcaUserId), + mapValue(room.registered, wcaUserId), + updatedAt, + ]); + } + + return { + knownUserIds, + roomId, + updatedAt, + }; +}; + +const upsertAttempt = async (client, room, roomState, attempt, attemptIndex) => { + const ordinal = Number.isInteger(attempt.id) ? attempt.id : attemptIndex; + const attemptMongoId = attempt._id + ? attempt._id.toString() + : `${room._id}:${room.event}:${attempt.createdAt || roomState.updatedAt}:${ordinal}`; + const attemptId = stableId('attempt', attemptMongoId); + const attemptUpdatedAt = sourceDate(attempt.updatedAt, roomState.updatedAt); + + await client.query(` + INSERT INTO app.attempts ( + id, mongo_id, room_id, ordinal, cube_event, scrambles, + source_created_at, source_updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (mongo_id) DO UPDATE SET + ordinal = EXCLUDED.ordinal, + cube_event = EXCLUDED.cube_event, + scrambles = EXCLUDED.scrambles, + source_updated_at = EXCLUDED.source_updated_at, + ingested_at = now() + WHERE app.attempts.source_updated_at < EXCLUDED.source_updated_at + OR ( + app.attempts.source_updated_at = EXCLUDED.source_updated_at + AND ROW( + app.attempts.ordinal, + app.attempts.cube_event, + app.attempts.scrambles + ) IS DISTINCT FROM ROW( + EXCLUDED.ordinal, + EXCLUDED.cube_event, + EXCLUDED.scrambles + ) + ) + `, [ + attemptId, + attemptMongoId, + roomState.roomId, + ordinal, + room.event, + JSON.stringify(attempt.scrambles || []), + attempt.createdAt || null, + attemptUpdatedAt, + ]); + + return { + attemptId, + attemptMongoId, + attemptUpdatedAt, + }; +}; + +const upsertSolve = async (client, roomState, attemptState, wcaUserId, result) => { + if (!wcaUserId || !roomState.knownUserIds.has(wcaUserId) || !result) { + return; + } + + const penalties = result.penalties || {}; + const resultUpdatedAt = sourceDate(result.updatedAt, attemptState.attemptUpdatedAt); + const resultCreatedAt = sourceDate(result.createdAt, resultUpdatedAt); + + await client.query(` + INSERT INTO app.solves ( + id, attempt_id, room_id, user_id, time_ms, dnf, + inspection_penalty, auf_penalty, source_created_at, source_updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (attempt_id, user_id) DO UPDATE SET + time_ms = EXCLUDED.time_ms, + dnf = EXCLUDED.dnf, + inspection_penalty = EXCLUDED.inspection_penalty, + auf_penalty = EXCLUDED.auf_penalty, + source_updated_at = EXCLUDED.source_updated_at, + ingested_at = now() + WHERE app.solves.source_updated_at < EXCLUDED.source_updated_at + OR ( + app.solves.source_updated_at = EXCLUDED.source_updated_at + AND ROW( + app.solves.time_ms, + app.solves.dnf, + app.solves.inspection_penalty, + app.solves.auf_penalty + ) IS DISTINCT FROM ROW( + EXCLUDED.time_ms, + EXCLUDED.dnf, + EXCLUDED.inspection_penalty, + EXCLUDED.auf_penalty + ) + ) + `, [ + stableId('solve', `${attemptState.attemptMongoId}:${wcaUserId}`), + attemptState.attemptId, + roomState.roomId, + stableId('user', wcaUserId), + result.time, + !!penalties.DNF, + !!penalties.inspection, + !!penalties.AUF, + resultCreatedAt, + resultUpdatedAt, + ]); +}; + +const syncAttemptResults = async (client, roomState, attemptState, attempt, userIds) => { + for (const userKey of userIds) { + const wcaUserId = numericUserId(userKey); + if (!wcaUserId || !roomState.knownUserIds.has(wcaUserId)) { + continue; + } + + const result = resultValue(attempt.results, userKey); + if (result) { + await upsertSolve(client, roomState, attemptState, wcaUserId, result); + } else { + await client.query(` + DELETE FROM app.solves + WHERE attempt_id = $1 AND user_id = $2 + `, [attemptState.attemptId, stableId('user', wcaUserId)]); + } + } +}; + +const writeRoomChanges = async (client, room, changes) => { + const resultUserIds = new Set(); + if (changes.replaceAttempts) { + (room.attempts || []).forEach((attempt) => { + resultEntries(attempt.results).forEach(([userId]) => resultUserIds.add(userId)); + }); + } else { + (changes.attempts || []).forEach((change) => { + if (change.syncAllResults) { + const attempt = room.attempts && room.attempts[change.attemptIndex]; + resultEntries(attempt && attempt.results) + .forEach(([userId]) => resultUserIds.add(userId)); + } else { + (change.resultUserIds || []).forEach((userId) => resultUserIds.add(userId)); + } + }); + } + + const roomState = await upsertRoomState(client, room, { + participantUserIds: changes.participantUserIds, + syncAllParticipants: changes.syncAllParticipants, + syncRoomOwners: changes.syncRoomOwners, + userIds: [...resultUserIds], + }); + if (!roomState) { + return null; + } + + if (changes.replaceAttempts) { + await client.query('DELETE FROM app.attempts WHERE room_id = $1', [roomState.roomId]); + for (const [attemptIndex, attempt] of (room.attempts || []).entries()) { + const attemptState = await upsertAttempt(client, room, roomState, attempt, attemptIndex); + await syncAttemptResults( + client, + roomState, + attemptState, + attempt, + resultEntries(attempt.results).map(([userId]) => userId), + ); + } + return roomState.roomId; + } + + for (const change of changes.attempts || []) { + const attempt = room.attempts && room.attempts[change.attemptIndex]; + if (!attempt) { + continue; + } + + const attemptState = await upsertAttempt( + client, + room, + roomState, + attempt, + change.attemptIndex, + ); + + if (change.syncAllResults) { + await client.query('DELETE FROM app.solves WHERE attempt_id = $1', [attemptState.attemptId]); + await syncAttemptResults( + client, + roomState, + attemptState, + attempt, + resultEntries(attempt.results).map(([userId]) => userId), + ); + } else { + await syncAttemptResults( + client, + roomState, + attemptState, + attempt, + change.resultUserIds || [], + ); + } + } + + return roomState.roomId; +}; + +const mirrorRoomChanges = (room, changes = {}) => withTransaction( + (client) => writeRoomChanges(client, room, changes), +); + +// Full snapshots are reserved for explicit backfills and do not delete newer live rows. +const mirrorRoom = (room) => mirrorRoomChanges(room, { + attempts: (room.attempts || []).map((attempt, attemptIndex) => ({ + attemptIndex, + resultUserIds: resultEntries(attempt.results).map(([userId]) => userId), + syncAllResults: false, + })), + syncAllParticipants: true, + syncRoomOwners: true, +}); + +const markRoomDeleted = (mongoId) => query(` + UPDATE app.rooms + SET deleted_at = now(), ingested_at = now() + WHERE mongo_id = $1 +`, [mongoId.toString()]); + +const mirrorMetricEvent = (event) => query(` + INSERT INTO analytics.events ( + id, event_name, occurred_at, actor_id, room_id, properties, expires_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (id) DO NOTHING +`, [ + event.eventId || uuidv4(), + event.event, + event.occurredAt, + event.actorId || null, + event.roomId || null, + { + actorType: event.actorType, + roomType: event.roomType, + cubeEvent: event.cubeEvent, + privateRoom: event.privateRoom, + failureReason: event.failureReason, + leaveReason: event.leaveReason, + activeUserCount: event.activeUserCount, + roomSolveCount: event.roomSolveCount, + durationMs: event.durationMs, + }, + event.expiresAt, +]); + +module.exports = { + markRoomDeleted, + mirrorMetricEvent, + mirrorRoom, + mirrorRoomChanges, + mirrorUser, + numericUserId, + stableId, +}; diff --git a/server/postgres/dualWrite.test.js b/server/postgres/dualWrite.test.js new file mode 100644 index 0000000..39503fe --- /dev/null +++ b/server/postgres/dualWrite.test.js @@ -0,0 +1,190 @@ +/** @jest-environment node */ +/* eslint-env jest */ + +jest.mock('./index', () => ({ + query: jest.fn(), + withTransaction: jest.fn(), +})); + +const postgres = require('./index'); +const { + markRoomDeleted, + mirrorMetricEvent, + mirrorRoom, + mirrorRoomChanges, + mirrorUser, + stableId, +} = require('./dualWrite'); + +const user = { + id: 1234, + email: 'solver@example.com', + name: 'Test Solver', + username: 'solver', + wcaId: '2026TEST01', + accessToken: 'must-not-be-mirrored', + showWCAID: true, + preferRealName: false, + useInspection: true, + timerType: 'spacebar', + muteTimer: false, + avatar: { thumb: 'avatar.png' }, +}; + +describe('PostgreSQL dual writer', () => { + let client; + + beforeEach(() => { + jest.clearAllMocks(); + client = { query: jest.fn().mockResolvedValue({ rows: [] }) }; + postgres.withTransaction.mockImplementation((callback) => callback(client)); + postgres.query.mockResolvedValue({ rows: [] }); + }); + + it('uses deterministic, domain-separated identifiers', () => { + expect(stableId('user', 1234)).toBe(stableId('user', 1234)); + expect(stableId('user', 1234)).not.toBe(stableId('room', 1234)); + }); + + it('mirrors users without copying OAuth access tokens', async () => { + await mirrorUser(user); + + expect(client.query).toHaveBeenCalledTimes(1); + const values = client.query.mock.calls[0][1]; + expect(values).toContain(1234); + expect(values).toContain('solver@example.com'); + expect(values).not.toContain('must-not-be-mirrored'); + }); + + it('mirrors a room snapshot, participants, attempts, and solves', async () => { + const updatedAt = new Date('2026-07-09T20:00:00.000Z'); + const room = { + _id: '507f1f77bcf86cd799439011', + name: 'Practice room', + event: '333', + accessCode: 'room-access-code', + password: 'bcrypt-hash', + type: 'normal', + owner: user, + admin: user, + users: [user], + competing: new Map([['1234', true]]), + waitingFor: new Map([['1234', false]]), + banned: new Map([['1234', false]]), + inRoom: new Map([['1234', true]]), + registered: new Map([['1234', true]]), + attempts: [{ + _id: '507f1f77bcf86cd799439012', + id: 0, + scrambles: ['R U R\''], + results: new Map([['1234', { + time: -1, + penalties: { DNF: true, inspection: true, AUF: false }, + createdAt: updatedAt, + updatedAt, + }]]), + createdAt: updatedAt, + updatedAt, + }], + requireRevealedIdentity: false, + started: false, + createdAt: updatedAt, + updatedAt, + }; + + await mirrorRoom(room); + + const statements = client.query.mock.calls.map(([sql]) => sql); + expect(statements.some((sql) => sql.includes('INSERT INTO app.users'))).toBe(true); + expect(statements.some((sql) => sql.includes('INSERT INTO app.rooms'))).toBe(true); + expect(statements.some((sql) => sql.includes('INSERT INTO app.room_participants'))).toBe(true); + expect(statements.some((sql) => sql.includes('INSERT INTO app.attempts'))).toBe(true); + expect(statements.some((sql) => sql.includes('INSERT INTO app.solves'))).toBe(true); + + const solveCall = client.query.mock.calls.find(([sql]) => sql.includes('INSERT INTO app.solves')); + expect(solveCall[0]).toContain('inspection_penalty'); + expect(solveCall[1].slice(4, 8)).toEqual([-1, true, true, false]); + expect(solveCall[1]).not.toContain('room-access-code'); + }); + + it('mirrors only explicitly changed attempts and results during live writes', async () => { + const updatedAt = new Date('2026-07-09T20:00:00.000Z'); + const unrelatedUser = { id: 5678, name: 'Unchanged Solver' }; + const room = { + _id: '507f1f77bcf86cd799439011', + name: 'Practice room', + event: '333', + accessCode: 'room-access-code', + type: 'normal', + owner: unrelatedUser, + admin: unrelatedUser, + users: [user, unrelatedUser], + competing: new Map([['1234', true], ['5678', true]]), + waitingFor: new Map([['1234', false], ['5678', true]]), + banned: new Map([['1234', false], ['5678', false]]), + inRoom: new Map([['1234', true], ['5678', true]]), + registered: new Map([['1234', true], ['5678', true]]), + attempts: [0, 1].map((id) => ({ + _id: `507f1f77bcf86cd79943901${id + 2}`, + id, + scrambles: ['R U R\''], + results: new Map([['1234', { + time: 12000 + id, + penalties: {}, + createdAt: updatedAt, + updatedAt, + }]]), + createdAt: updatedAt, + updatedAt, + })), + requireRevealedIdentity: false, + started: false, + createdAt: updatedAt, + updatedAt, + }; + + await mirrorRoomChanges(room, { + attempts: [{ + attemptIndex: 1, + resultUserIds: ['1234'], + syncAllResults: false, + }], + participantUserIds: ['1234'], + }); + + const statements = client.query.mock.calls.map(([sql]) => sql); + expect(statements.filter((sql) => sql.includes('INSERT INTO app.users'))).toHaveLength(1); + expect(statements.filter((sql) => sql.includes('INSERT INTO app.room_participants'))) + .toHaveLength(1); + expect(statements.filter((sql) => sql.includes('INSERT INTO app.attempts'))).toHaveLength(1); + expect(statements.filter((sql) => sql.includes('INSERT INTO app.solves'))).toHaveLength(1); + expect(statements.some((sql) => sql.includes('DELETE FROM app.attempts'))).toBe(false); + expect(client.query.mock.calls.find(([sql]) => sql.includes('INSERT INTO app.solves'))[1]) + .toContain(12001); + }); + + it('mirrors sanitized metric events and soft-deletes rooms', async () => { + const occurredAt = new Date('2026-07-09T20:00:00.000Z'); + await mirrorMetricEvent({ + eventId: 'ad6bdef6-71f9-4264-b920-2b1e6ac061d5', + event: 'room_joined', + occurredAt, + expiresAt: new Date('2026-10-07T20:00:00.000Z'), + actorId: 'pseudonymous-user', + roomId: 'pseudonymous-room', + activeUserCount: 4, + }); + await markRoomDeleted('507f1f77bcf86cd799439011'); + + expect(postgres.query).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('INSERT INTO analytics.events'), + expect.arrayContaining(['room_joined', 'pseudonymous-user', 'pseudonymous-room']), + ); + expect(postgres.query).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('UPDATE app.rooms'), + ['507f1f77bcf86cd799439011'], + ); + }); +}); diff --git a/server/postgres/index.js b/server/postgres/index.js new file mode 100644 index 0000000..47242f4 --- /dev/null +++ b/server/postgres/index.js @@ -0,0 +1,125 @@ +const { Pool } = require('pg'); + +const config = require('../runtimeConfig'); +const logger = require('../logger'); + +const RETRY_DELAY_MS = 30 * 1000; + +const poolConfig = config.postgres.connectionString + ? { connectionString: config.postgres.connectionString } + : { + host: config.postgres.host, + port: config.postgres.port, + database: config.postgres.database, + user: config.postgres.user, + password: config.postgres.password, + }; + +if (config.postgres.ssl) { + poolConfig.ssl = { + rejectUnauthorized: config.postgres.sslRejectUnauthorized, + ...(config.postgres.sslCa ? { ca: config.postgres.sslCa } : {}), + }; +} + +const pool = new Pool({ + ...poolConfig, + connectionTimeoutMillis: 3000, + idleTimeoutMillis: 30000, + max: 10, +}); + +pool.on('error', (err) => { + logger.error(err); +}); + +let initialized = false; +let initializationPromise; +let lastFailureAt = 0; + +const initializePostgres = async () => { + if (!config.postgres.enabled) { + return false; + } + if (initialized) { + return true; + } + if (initializationPromise) { + return initializationPromise; + } + if (Date.now() - lastFailureAt < RETRY_DELAY_MS) { + return false; + } + + initializationPromise = (async () => { + await pool.query('SELECT 1'); + initialized = true; + logger.info('[POSTGRES] Connected'); + return true; + })().catch((err) => { + lastFailureAt = Date.now(); + logger.error(err); + return false; + }).finally(() => { + initializationPromise = undefined; + }); + + return initializationPromise; +}; + +const withTransaction = async (callback) => { + if (!(await initializePostgres())) { + return null; + } + + let client; + try { + client = await pool.connect(); + await client.query('BEGIN'); + const result = await callback(client); + await client.query('COMMIT'); + return result; + } catch (err) { + if (client) { + await client.query('ROLLBACK').catch(() => {}); + } + logger.error(err); + return null; + } finally { + if (client) { + client.release(); + } + } +}; + +const query = async (...args) => { + if (!(await initializePostgres())) { + return null; + } + + try { + return await pool.query(...args); + } catch (err) { + logger.error(err); + return null; + } +}; + +const deleteExpiredMetrics = () => query( + 'DELETE FROM analytics.events WHERE expires_at <= now()', +); + +const startPostgresMaintenance = () => { + deleteExpiredMetrics(); + const timer = setInterval(deleteExpiredMetrics, 6 * 60 * 60 * 1000); + timer.unref(); +}; + +module.exports = { + deleteExpiredMetrics, + initializePostgres, + pool, + query, + startPostgresMaintenance, + withTransaction, +}; diff --git a/server/prisma.config.mjs b/server/prisma.config.mjs new file mode 100644 index 0000000..eba7576 --- /dev/null +++ b/server/prisma.config.mjs @@ -0,0 +1,28 @@ +import { defineConfig } from 'prisma/config'; + +const databaseUrl = () => { + if (process.env.DIRECT_DATABASE_URL || process.env.DATABASE_URL) { + return process.env.DIRECT_DATABASE_URL || process.env.DATABASE_URL; + } + + const user = encodeURIComponent(process.env.PGUSER || 'letscube'); + const password = encodeURIComponent(process.env.PGPASSWORD + || process.env.POSTGRES_PASSWORD + || 'letscube'); + const host = process.env.PGHOST || '127.0.0.1'; + const port = process.env.PGPORT || '5432'; + const database = encodeURIComponent(process.env.PGDATABASE || 'letscube'); + const ssl = process.env.PGSSL === 'true' ? '?sslmode=require' : ''; + + return `postgresql://${user}:${password}@${host}:${port}/${database}${ssl}`; +}; + +export default defineConfig({ + schema: 'prisma/schema.prisma', + migrations: { + path: 'prisma/migrations', + }, + datasource: { + url: databaseUrl(), + }, +}); diff --git a/server/prisma/migrations/20260709230000_initial_schema/migration.sql b/server/prisma/migrations/20260709230000_initial_schema/migration.sql new file mode 100644 index 0000000..fd0f015 --- /dev/null +++ b/server/prisma/migrations/20260709230000_initial_schema/migration.sql @@ -0,0 +1,107 @@ +CREATE SCHEMA IF NOT EXISTS analytics; +CREATE SCHEMA IF NOT EXISTS app; + +CREATE TABLE app.users ( + id uuid PRIMARY KEY, + wca_user_id bigint NOT NULL UNIQUE, + email text, + name text NOT NULL, + username text, + wca_id text, + preferences jsonb NOT NULL DEFAULT '{}'::jsonb, + avatar jsonb NOT NULL DEFAULT '{}'::jsonb, + source_created_at timestamptz, + source_updated_at timestamptz NOT NULL, + ingested_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX users_username_lower_idx + ON app.users (lower(username)) + WHERE username IS NOT NULL AND username <> ''; + +CREATE TABLE app.rooms ( + id uuid PRIMARY KEY, + mongo_id text NOT NULL UNIQUE, + name text NOT NULL, + cube_event text NOT NULL, + access_code text NOT NULL UNIQUE, + password_hash text, + room_type text NOT NULL CHECK (room_type IN ('normal', 'grand_prix')), + owner_id uuid REFERENCES app.users(id) ON DELETE SET NULL, + admin_id uuid REFERENCES app.users(id) ON DELETE SET NULL, + require_revealed_identity boolean NOT NULL DEFAULT false, + start_time timestamptz, + started boolean NOT NULL DEFAULT false, + next_solve_at timestamptz, + expires_at timestamptz, + twitch_channel text, + deleted_at timestamptz, + source_created_at timestamptz, + source_updated_at timestamptz NOT NULL, + ingested_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE app.room_participants ( + room_id uuid NOT NULL REFERENCES app.rooms(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES app.users(id) ON DELETE CASCADE, + competing boolean NOT NULL DEFAULT false, + waiting_for boolean NOT NULL DEFAULT false, + banned boolean NOT NULL DEFAULT false, + in_room boolean NOT NULL DEFAULT false, + registered boolean NOT NULL DEFAULT false, + source_updated_at timestamptz NOT NULL, + ingested_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (room_id, user_id) +); + +CREATE TABLE app.attempts ( + id uuid PRIMARY KEY, + mongo_id text NOT NULL CONSTRAINT attempts_mongo_id_unique UNIQUE, + room_id uuid NOT NULL REFERENCES app.rooms(id) ON DELETE CASCADE, + ordinal integer NOT NULL CHECK (ordinal >= 0), + cube_event text NOT NULL, + scrambles jsonb NOT NULL, + source_created_at timestamptz, + source_updated_at timestamptz NOT NULL, + ingested_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX attempts_room_ordinal_idx + ON app.attempts (room_id, ordinal); + +CREATE TABLE app.solves ( + id uuid PRIMARY KEY, + attempt_id uuid NOT NULL REFERENCES app.attempts(id) ON DELETE CASCADE, + room_id uuid NOT NULL REFERENCES app.rooms(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES app.users(id) ON DELETE CASCADE, + time_ms integer NOT NULL CHECK (time_ms >= -1), + penalties jsonb NOT NULL DEFAULT '{}'::jsonb, + source_created_at timestamptz, + source_updated_at timestamptz NOT NULL, + ingested_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (attempt_id, user_id) +); + +CREATE INDEX solves_user_created_idx + ON app.solves (user_id, source_created_at DESC); + +CREATE TABLE analytics.events ( + id uuid PRIMARY KEY, + event_name text NOT NULL, + occurred_at timestamptz NOT NULL, + actor_id text, + room_id text, + properties jsonb NOT NULL DEFAULT '{}'::jsonb, + expires_at timestamptz NOT NULL, + ingested_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX analytics_events_name_time_idx + ON analytics.events (event_name, occurred_at DESC); + +CREATE INDEX analytics_events_room_time_idx + ON analytics.events (room_id, occurred_at DESC) + WHERE room_id IS NOT NULL; + +CREATE INDEX analytics_events_expiry_idx + ON analytics.events (expires_at); diff --git a/server/prisma/migrations/20260709234500_normalize_solve_penalties/migration.sql b/server/prisma/migrations/20260709234500_normalize_solve_penalties/migration.sql new file mode 100644 index 0000000..70ea34a --- /dev/null +++ b/server/prisma/migrations/20260709234500_normalize_solve_penalties/migration.sql @@ -0,0 +1,20 @@ +ALTER TABLE app.solves + ADD COLUMN dnf boolean NOT NULL DEFAULT false, + ADD COLUMN inspection_penalty boolean NOT NULL DEFAULT false, + ADD COLUMN auf_penalty boolean NOT NULL DEFAULT false; + +UPDATE app.solves +SET + dnf = COALESCE((penalties ->> 'DNF')::boolean, false), + inspection_penalty = COALESCE((penalties ->> 'inspection')::boolean, false), + auf_penalty = COALESCE((penalties ->> 'AUF')::boolean, false), + source_created_at = COALESCE(source_created_at, source_updated_at); + +ALTER TABLE app.solves + ALTER COLUMN source_created_at SET NOT NULL, + DROP COLUMN penalties; + +DROP INDEX app.solves_user_created_idx; + +CREATE INDEX solves_user_created_idx + ON app.solves (user_id, source_created_at DESC, id DESC); diff --git a/server/prisma/migrations/migration_lock.toml b/server/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2fe25d8 --- /dev/null +++ b/server/prisma/migrations/migration_lock.toml @@ -0,0 +1 @@ +provider = "postgresql" diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma new file mode 100644 index 0000000..25c9a74 --- /dev/null +++ b/server/prisma/schema.prisma @@ -0,0 +1,130 @@ +datasource db { + provider = "postgresql" + schemas = ["analytics", "app"] +} + +model User { + id String @id @db.Uuid + wcaUserId BigInt @unique(map: "users_wca_user_id_key") @map("wca_user_id") + email String? + name String + username String? + wcaId String? @map("wca_id") + preferences Json @default("{}") @db.JsonB + avatar Json @default("{}") @db.JsonB + sourceCreatedAt DateTime? @map("source_created_at") @db.Timestamptz(6) + sourceUpdatedAt DateTime @map("source_updated_at") @db.Timestamptz(6) + ingestedAt DateTime @default(now()) @map("ingested_at") @db.Timestamptz(6) + ownedRooms Room[] @relation("RoomOwner") + administeredRooms Room[] @relation("RoomAdmin") + roomParticipations RoomParticipant[] + solves Solve[] + + @@map("users") + @@schema("app") +} + +model Room { + id String @id @db.Uuid + mongoId String @unique(map: "rooms_mongo_id_key") @map("mongo_id") + name String + cubeEvent String @map("cube_event") + accessCode String @unique(map: "rooms_access_code_key") @map("access_code") + passwordHash String? @map("password_hash") + roomType String @map("room_type") + ownerId String? @map("owner_id") @db.Uuid + adminId String? @map("admin_id") @db.Uuid + requireRevealedIdentity Boolean @default(false) @map("require_revealed_identity") + startTime DateTime? @map("start_time") @db.Timestamptz(6) + started Boolean @default(false) + nextSolveAt DateTime? @map("next_solve_at") @db.Timestamptz(6) + expiresAt DateTime? @map("expires_at") @db.Timestamptz(6) + twitchChannel String? @map("twitch_channel") + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + sourceCreatedAt DateTime? @map("source_created_at") @db.Timestamptz(6) + sourceUpdatedAt DateTime @map("source_updated_at") @db.Timestamptz(6) + ingestedAt DateTime @default(now()) @map("ingested_at") @db.Timestamptz(6) + owner User? @relation("RoomOwner", fields: [ownerId], references: [id], onDelete: SetNull, onUpdate: NoAction) + admin User? @relation("RoomAdmin", fields: [adminId], references: [id], onDelete: SetNull, onUpdate: NoAction) + participants RoomParticipant[] + attempts Attempt[] + solves Solve[] + + @@map("rooms") + @@schema("app") +} + +model RoomParticipant { + roomId String @map("room_id") @db.Uuid + userId String @map("user_id") @db.Uuid + competing Boolean @default(false) + waitingFor Boolean @default(false) @map("waiting_for") + banned Boolean @default(false) + inRoom Boolean @default(false) @map("in_room") + registered Boolean @default(false) + sourceUpdatedAt DateTime @map("source_updated_at") @db.Timestamptz(6) + ingestedAt DateTime @default(now()) @map("ingested_at") @db.Timestamptz(6) + room Room @relation(fields: [roomId], references: [id], onDelete: Cascade, onUpdate: NoAction) + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@id([roomId, userId]) + @@map("room_participants") + @@schema("app") +} + +model Attempt { + id String @id @db.Uuid + mongoId String @unique(map: "attempts_mongo_id_unique") @map("mongo_id") + roomId String @map("room_id") @db.Uuid + ordinal Int + cubeEvent String @map("cube_event") + scrambles Json @db.JsonB + sourceCreatedAt DateTime? @map("source_created_at") @db.Timestamptz(6) + sourceUpdatedAt DateTime @map("source_updated_at") @db.Timestamptz(6) + ingestedAt DateTime @default(now()) @map("ingested_at") @db.Timestamptz(6) + room Room @relation(fields: [roomId], references: [id], onDelete: Cascade, onUpdate: NoAction) + solves Solve[] + + @@index([roomId, ordinal], map: "attempts_room_ordinal_idx") + @@map("attempts") + @@schema("app") +} + +model Solve { + id String @id @db.Uuid + attemptId String @map("attempt_id") @db.Uuid + roomId String @map("room_id") @db.Uuid + userId String @map("user_id") @db.Uuid + timeMs Int @map("time_ms") + dnf Boolean @default(false) + inspectionPenalty Boolean @default(false) @map("inspection_penalty") + aufPenalty Boolean @default(false) @map("auf_penalty") + sourceCreatedAt DateTime @map("source_created_at") @db.Timestamptz(6) + sourceUpdatedAt DateTime @map("source_updated_at") @db.Timestamptz(6) + ingestedAt DateTime @default(now()) @map("ingested_at") @db.Timestamptz(6) + attempt Attempt @relation(fields: [attemptId], references: [id], onDelete: Cascade, onUpdate: NoAction) + room Room @relation(fields: [roomId], references: [id], onDelete: Cascade, onUpdate: NoAction) + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@unique([attemptId, userId], map: "solves_attempt_id_user_id_key") + @@index([userId, sourceCreatedAt(sort: Desc), id(sort: Desc)], map: "solves_user_created_idx") + @@map("solves") + @@schema("app") +} + +model MetricEvent { + id String @id @db.Uuid + eventName String @map("event_name") + occurredAt DateTime @map("occurred_at") @db.Timestamptz(6) + actorId String? @map("actor_id") + roomId String? @map("room_id") + properties Json @default("{}") @db.JsonB + expiresAt DateTime @map("expires_at") @db.Timestamptz(6) + ingestedAt DateTime @default(now()) @map("ingested_at") @db.Timestamptz(6) + + @@index([eventName, occurredAt(sort: Desc)], map: "analytics_events_name_time_idx") + @@index([roomId, occurredAt(sort: Desc)], map: "analytics_events_room_time_idx") + @@index([expiresAt], map: "analytics_events_expiry_idx") + @@map("events") + @@schema("analytics") +} diff --git a/server/runtimeConfig.js b/server/runtimeConfig.js index 1762c33..382b628 100644 --- a/server/runtimeConfig.js +++ b/server/runtimeConfig.js @@ -13,6 +13,15 @@ const parseOrigins = (value, fallback) => { return value.split(',').map((origin) => origin.trim()).filter(Boolean); }; +const parsePositiveInteger = (value, fallback) => { + const parsed = Number.parseInt(value, 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +}; + +const authSecret = process.env.AUTH_SECRET + || process.env.SESSION_SECRET + || baseConfig.auth.secret; + const redisUrl = process.env.REDIS_URL || (baseConfig.redis && baseConfig.redis.url) || 'redis://localhost:6379'; @@ -55,7 +64,7 @@ module.exports = { wcaSource: process.env.WCA_SOURCE || process.env.REACT_APP_WCA_ORIGIN || baseConfig.wcaSource, auth: { ...baseConfig.auth, - secret: process.env.AUTH_SECRET || process.env.SESSION_SECRET || baseConfig.auth.secret, + secret: authSecret, callbackURL: process.env.AUTH_CALLBACK_URL || baseConfig.auth.callbackURL || baseConfig.auth.callbackUrl, @@ -71,4 +80,26 @@ module.exports = { ...baseConfig.cors, origin: parseOrigins(process.env.CORS_ORIGINS, baseConfig.cors.origin), }, + metrics: { + enabled: process.env.METRICS_ENABLED !== 'false', + hashSecret: process.env.METRICS_HASH_SECRET || authSecret, + retentionDays: parsePositiveInteger(process.env.METRICS_RETENTION_DAYS, 90), + }, + postgres: { + enabled: process.env.POSTGRES_ENABLED !== 'false', + connectionString: process.env.DATABASE_URL, + host: process.env.PGHOST || (baseConfig.postgres && baseConfig.postgres.host) || '127.0.0.1', + port: parsePort(process.env.PGPORT, (baseConfig.postgres && baseConfig.postgres.port) || 5432), + database: process.env.PGDATABASE + || (baseConfig.postgres && baseConfig.postgres.database) + || 'letscube', + user: process.env.PGUSER || (baseConfig.postgres && baseConfig.postgres.user) || 'letscube', + password: process.env.PGPASSWORD + || process.env.POSTGRES_PASSWORD + || (baseConfig.postgres && baseConfig.postgres.password) + || 'letscube', + ssl: process.env.PGSSL === 'true', + sslRejectUnauthorized: process.env.PGSSL_REJECT_UNAUTHORIZED !== 'false', + sslCa: process.env.PGSSL_CA && process.env.PGSSL_CA.replace(/\\n/g, '\n'), + }, }; diff --git a/server/socket/index.js b/server/socket/index.js index 18a5d10..39f7f30 100644 --- a/server/socket/index.js +++ b/server/socket/index.js @@ -7,6 +7,7 @@ const expressSocketSession = require('express-socket.io-session'); const config = require('../runtimeConfig'); const session = require('../middlewares/session'); const { connect } = require('../database'); +const { initializePostgres } = require('../postgres'); const logger = require('../logger'); const loggerMiddleware = require('./middlewares/logger'); const authenticateMiddleware = require('./middlewares/authenticate'); @@ -49,6 +50,7 @@ const init = async () => { io.on('error', logSocketError('server')); const mongoose = await connect(); + await initializePostgres(); const pubClient = new Redis(config.redis.url || { host: config.redis.host, diff --git a/server/socket/lib/commands.js b/server/socket/lib/commands.js index 08bdc5e..dbd5e6d 100644 --- a/server/socket/lib/commands.js +++ b/server/socket/lib/commands.js @@ -1,4 +1,4 @@ -const Protocol = require('../../../client/src/lib/protocol'); +const Protocol = require('../../../client/src/lib/protocol.json'); const ChatMessage = require('./ChatMessage'); const Commands = { diff --git a/server/socket/namespaces/default.js b/server/socket/namespaces/default.js index 5266f81..e4284e0 100644 --- a/server/socket/namespaces/default.js +++ b/server/socket/namespaces/default.js @@ -1,5 +1,5 @@ const logger = require('../../logger'); -const Protocol = require('../../../client/src/lib/protocol'); +const Protocol = require('../../../client/src/lib/protocol.json'); module.exports = (io, middlewares) => { const ns = io.of('/'); diff --git a/server/socket/namespaces/rooms.js b/server/socket/namespaces/rooms.js index 877f013..862d26e 100644 --- a/server/socket/namespaces/rooms.js +++ b/server/socket/namespaces/rooms.js @@ -1,9 +1,11 @@ const _ = require('lodash'); const bcrypt = require('bcrypt'); -const Protocol = require('../../../client/src/lib/protocol'); +const Protocol = require('../../../client/src/lib/protocol.json'); const ChatMessage = require('../lib/ChatMessage'); const { parseCommand } = require('../lib/commands'); const logger = require('../../logger'); +const metrics = require('../../metrics'); +const { markRoomDeleted } = require('../../postgres/dualWrite'); const { Room, User } = require('../../models'); const { encodeUserRoom } = require('../utils'); const roomMap = require('../lib/roomMap'); @@ -271,7 +273,7 @@ module.exports = (io, middlewares) => { } // Only deals with removing authenticated users from a room - async function leaveRoom() { + async function leaveRoom(leaveReason) { try { const hasOtherSockets = await hasOtherSocketsForUserRoom( socket.userId, @@ -286,6 +288,14 @@ module.exports = (io, middlewares) => { ns().in(socket.room.accessCode).emit(Protocol.UPDATE_ADMIN, _room.admin); }); + await metrics.endRoomVisit({ + room, + userId: socket.userId, + connectionId: socket.id, + leaveReason, + activeUserCount: room.usersLength, + }); + broadcast(Protocol.USER_LEFT, socket.user.id); ns().emit(Protocol.GLOBAL_ROOM_UPDATED, roomMask(room)); @@ -301,6 +311,12 @@ module.exports = (io, middlewares) => { async function joinRoom(room, cb, spectating) { if (socket.roomId) { logger.debug('Socket is already in room', { roomId: socket.room._id }); + await metrics.recordRoomJoinFailure({ + room: socket.room, + userId: socket.userId, + connectionId: socket.id, + failureReason: 'already_in_room', + }); return cb({ statusCode: 400, message: 'Socket is already in room', @@ -312,6 +328,12 @@ module.exports = (io, middlewares) => { if (!socket.user) { logger.debug('Socket is not authenticated but joining anyways', { roomId: room._id, userId: socket.userId }); + socket.room = room; + await metrics.beginRoomVisit({ + room, + connectionId: socket.id, + activeUserCount: room.usersLength, + }); return cb(null, joinRoomMask(room)); } @@ -323,10 +345,24 @@ module.exports = (io, middlewares) => { if (!r) { // Join the socket to the room anyways but don't add them + socket.room = room; + await metrics.beginRoomVisit({ + room, + userId: socket.userId, + connectionId: socket.id, + activeUserCount: room.usersLength, + }); return cb(null, joinRoomMask(room)); } socket.room = r; + await metrics.beginRoomVisit({ + room: r, + userId: socket.userId, + connectionId: socket.id, + activeUserCount: r.usersLength, + replaceActive: true, + }); socket.emit(Protocol.JOIN, joinRoomMask(r)); cb(null, joinRoomMask(r)); @@ -344,49 +380,59 @@ module.exports = (io, middlewares) => { const acknowledgment = optionalAcknowledgment(cb); const { id, spectating, password } = payload || {}; + const rejectJoin = async (failureReason, error, room) => { + await metrics.recordRoomJoinFailure({ + room, + userId: socket.userId, + connectionId: socket.id, + failureReason, + }); + return acknowledgment(error, room ? roomMask(room) : undefined); + }; + try { const room = await fetchRoom(id); if (!room) { - return acknowledgment({ + return rejectJoin('not_found', { statusCode: 404, message: `Could not find room with id ${id}`, }); } if (room.private && !password) { - return acknowledgment({ + return rejectJoin('password_required', { statusCode: 403, message: 'Room requires password to join', - }, roomMask(room)); + }, room); } if (room.private && !(await room.authenticate(password))) { - return acknowledgment({ + return rejectJoin('invalid_password', { statusCode: 403, message: 'Invalid password', - }, roomMask(room)); + }, room); } if (socket.userId && room.banned.get(socket.userId.toString())) { logger.debug(`Banned user ${socket.user.id} is trying to join room ${room._id}`); - return acknowledgment({ + return rejectJoin('banned', { statusCode: 401, message: 'Banned', banned: true, - }, roomMask(room)); + }, room); } if (room.requireRevealedIdentity && (!socket.user || !socket.user.showWCAID)) { - return acknowledgment({ + return rejectJoin('identity_required', { statusCode: 403, message: 'Must be showing WCA Identity to join room.', - }, roomMask(room)); + }, room); } return await joinRoom(room, acknowledgment, spectating); } catch (e) { logger.error(e); - return acknowledgment({ + return rejectJoin('internal_error', { statusCode: 500, message: 'Failed to join room', }); @@ -428,6 +474,10 @@ module.exports = (io, middlewares) => { newRoom.owner = socket.user; const room = await newRoom.save(); + await metrics.recordRoomCreated({ + room, + userId: socket.userId, + }); ns().emit(Protocol.ROOM_CREATED, roomMask(room)); return joinRoom(room, (err, r) => { if (err) { @@ -463,6 +513,7 @@ module.exports = (io, middlewares) => { const res = await Room.deleteOne({ _id: room._id }); if (res.deletedCount > 0) { + await markRoomDeleted(room._id); socket.room = undefined; acknowledgment(null); ns().emit(Protocol.ROOM_DELETED, id); @@ -538,11 +589,19 @@ module.exports = (io, middlewares) => { result.penalties.DNF = result.penalties.DNF || id < socket.room.attempts.length - 1; } + const previousResult = socket.room.attempts[id].results.get(socket.user.id.toString()); socket.room.attempts[id].results.set(socket.user.id.toString(), result); socket.room.waitingFor.set(socket.user.id.toString(), false); const r = await socket.room.save(); + if (!previousResult) { + await metrics.recordRoomResult({ + room: r, + userId: socket.userId, + }); + } + ns().in(r.accessCode).emit(Protocol.NEW_RESULT, { id, result, @@ -668,6 +727,13 @@ module.exports = (io, middlewares) => { try { const room = await socket.room.dropUser({ id: userId }); + await metrics.endRoomVisit({ + room, + userId, + leaveReason: 'kick', + activeUserCount: room.usersLength, + }); + ns().in(encodeUserRoom(userId, socket.room._id)).emit(Protocol.KICKED); removeUserFromRoomSockets(ns(), userId, socket.room); @@ -695,6 +761,13 @@ module.exports = (io, middlewares) => { try { const room = await socket.room.banUser(userId); + await metrics.endRoomVisit({ + room, + userId, + leaveReason: 'ban', + activeUserCount: room.usersLength, + }); + ns().in(encodeUserRoom(userId, socket.room._id)).emit(Protocol.BANNED); removeUserFromRoomSockets(ns(), userId, socket.room); @@ -765,7 +838,14 @@ module.exports = (io, middlewares) => { logger.info(`socket ${socket.id} disconnected; Left room: ${socket.room ? socket.room.name : 'Null'}`, { roomId: socket.roomId }); if (socket.user && socket.room) { - await leaveRoom(); + await leaveRoom('disconnect'); + } else if (socket.room) { + await metrics.endRoomVisit({ + room: socket.room, + connectionId: socket.id, + leaveReason: 'disconnect', + activeUserCount: socket.room.usersLength, + }); } updateClientsWithUsers(); @@ -777,8 +857,15 @@ module.exports = (io, middlewares) => { on(Protocol.LEAVE_ROOM, async () => { if (socket.room) { if (socket.user) { - await leaveRoom(); + await leaveRoom('explicit'); socket.leave(encodeUserRoom(socket.userId, socket.room._id)); + } else { + await metrics.endRoomVisit({ + room: socket.room, + connectionId: socket.id, + leaveReason: 'explicit', + activeUserCount: socket.room.usersLength, + }); } socket.leave(socket.room.accessCode); diff --git a/yarn.lock b/yarn.lock index adaf1c0..2a44c39 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1554,6 +1554,21 @@ debug "^3.1.0" lodash.once "^4.1.1" +"@electric-sql/pglite-socket@0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@electric-sql/pglite-socket/-/pglite-socket-0.1.1.tgz#af443da3a60130aee254faee4daf50fb79fb8d1f" + integrity sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw== + +"@electric-sql/pglite-tools@0.3.1": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@electric-sql/pglite-tools/-/pglite-tools-0.3.1.tgz#b1b23dc45dcce22fb4d5a0505ba063923d09c105" + integrity sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA== + +"@electric-sql/pglite@0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@electric-sql/pglite/-/pglite-0.4.1.tgz#a113476c3c20539756a8d77eb86d248d84a8d097" + integrity sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q== + "@emnapi/core@1.11.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.11.1.tgz#b9e1064f3a6b1631e241e638eb48d736bfd372a6" @@ -1581,6 +1596,11 @@ resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.8.0.tgz#bbbff68978fefdbe68ccb533bc8cbe1d1afb5413" integrity sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow== +"@hono/node-server@1.19.11", "@hono/node-server@1.19.13": + version "1.19.13" + resolved "https://registry.yarnpkg.com/@hono/node-server/-/node-server-1.19.13.tgz#4838c766a1237253d4dde3281cf7d5c65186fd32" + integrity sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ== + "@ioredis/commands@1.10.0": version "1.10.0" resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.10.0.tgz#cc387f8ec5ebe5b3b5104d393b5ac1f9cf794b9a" @@ -1940,6 +1960,11 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@kurkle/color@^0.3.0": + version "0.3.4" + resolved "https://registry.yarnpkg.com/@kurkle/color/-/color-0.3.4.tgz#4d4ff677e1609214fc71c580125ddddd86abcabf" + integrity sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w== + "@material-ui/core@^4.11.2": version "4.12.4" resolved "https://registry.yarnpkg.com/@material-ui/core/-/core-4.12.4.tgz#4ac17488e8fcaf55eb6a7f5efb2a131e10138a73" @@ -2041,6 +2066,163 @@ resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.139.0.tgz#38d76b9dbf934c2a02be174fb32ceebf182fe742" integrity sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw== +"@prisma/config@7.8.0": + version "7.8.0" + resolved "https://registry.yarnpkg.com/@prisma/config/-/config-7.8.0.tgz#401f1f108f2e463e508ac20ca08979d4ee215c65" + integrity sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw== + dependencies: + c12 "3.3.4" + deepmerge-ts "7.1.5" + effect "3.20.0" + empathic "2.0.0" + +"@prisma/debug@7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@prisma/debug/-/debug-7.2.0.tgz#569b1cbc10eb3e8cae798b40075fd11d21f6b533" + integrity sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw== + +"@prisma/debug@7.8.0": + version "7.8.0" + resolved "https://registry.yarnpkg.com/@prisma/debug/-/debug-7.8.0.tgz#8e2f70d284b3091c2d713aa093a0f5898487e431" + integrity sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA== + +"@prisma/dev@0.24.3": + version "0.24.3" + resolved "https://registry.yarnpkg.com/@prisma/dev/-/dev-0.24.3.tgz#a235c2cfca28134f904e6b964d7652a9dfbd60f4" + integrity sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg== + dependencies: + "@electric-sql/pglite" "0.4.1" + "@electric-sql/pglite-socket" "0.1.1" + "@electric-sql/pglite-tools" "0.3.1" + "@hono/node-server" "1.19.11" + "@prisma/get-platform" "7.2.0" + "@prisma/query-plan-executor" "7.2.0" + "@prisma/streams-local" "0.1.2" + foreground-child "3.3.1" + get-port-please "3.2.0" + hono "^4.12.8" + http-status-codes "2.3.0" + pathe "2.0.3" + proper-lockfile "4.1.2" + remeda "2.33.4" + std-env "3.10.0" + valibot "1.2.0" + zeptomatch "2.1.0" + +"@prisma/engines-version@7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a": + version "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a" + resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a.tgz#6ab01f7c2619a9f9f1634418288a08080a630d18" + integrity sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA== + +"@prisma/engines@7.8.0": + version "7.8.0" + resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-7.8.0.tgz#214c778871929bcf96a056285270ddf1d74e7051" + integrity sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw== + dependencies: + "@prisma/debug" "7.8.0" + "@prisma/engines-version" "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a" + "@prisma/fetch-engine" "7.8.0" + "@prisma/get-platform" "7.8.0" + +"@prisma/fetch-engine@7.8.0": + version "7.8.0" + resolved "https://registry.yarnpkg.com/@prisma/fetch-engine/-/fetch-engine-7.8.0.tgz#699c1876d862b1f965b0318eb5e80b6c3744aa3f" + integrity sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ== + dependencies: + "@prisma/debug" "7.8.0" + "@prisma/engines-version" "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a" + "@prisma/get-platform" "7.8.0" + +"@prisma/get-platform@7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@prisma/get-platform/-/get-platform-7.2.0.tgz#b3a92db68de6a76e840e61d2f26659aa9f915e3e" + integrity sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA== + dependencies: + "@prisma/debug" "7.2.0" + +"@prisma/get-platform@7.8.0": + version "7.8.0" + resolved "https://registry.yarnpkg.com/@prisma/get-platform/-/get-platform-7.8.0.tgz#7b1dade4117939c68c1324150ac72773f7272e68" + integrity sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw== + dependencies: + "@prisma/debug" "7.8.0" + +"@prisma/query-plan-executor@7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz#00b218d78066957f25ccae0954bbef708396cc9f" + integrity sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ== + +"@prisma/streams-local@0.1.2": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@prisma/streams-local/-/streams-local-0.1.2.tgz#531679bf13aafe4c663778848ff61a106b95df27" + integrity sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg== + dependencies: + ajv "^8.12.0" + better-result "^2.7.0" + env-paths "^3.0.0" + proper-lockfile "^4.1.2" + +"@prisma/studio-core@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@prisma/studio-core/-/studio-core-0.27.3.tgz#45246d76565ada1728bc7e0d29217c0e4746a631" + integrity sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw== + dependencies: + "@radix-ui/react-toggle" "1.1.10" + chart.js "4.5.1" + +"@radix-ui/primitive@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.3.tgz#e2dbc13bdc5e4168f4334f75832d7bdd3e2de5ba" + integrity sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg== + +"@radix-ui/react-compose-refs@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz#a2c4c47af6337048ee78ff6dc0d090b390d2bb30" + integrity sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg== + +"@radix-ui/react-primitive@2.1.3": + version "2.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz#db9b8bcff49e01be510ad79893fb0e4cda50f1bc" + integrity sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ== + dependencies: + "@radix-ui/react-slot" "1.2.3" + +"@radix-ui/react-slot@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz#502d6e354fc847d4169c3bc5f189de777f68cfe1" + integrity sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A== + dependencies: + "@radix-ui/react-compose-refs" "1.1.2" + +"@radix-ui/react-toggle@1.1.10": + version "1.1.10" + resolved "https://registry.yarnpkg.com/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz#b04ba0f9609599df666fce5b2f38109a197f08cf" + integrity sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ== + dependencies: + "@radix-ui/primitive" "1.1.3" + "@radix-ui/react-primitive" "2.1.3" + "@radix-ui/react-use-controllable-state" "1.2.2" + +"@radix-ui/react-use-controllable-state@1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz#905793405de57d61a439f4afebbb17d0645f3190" + integrity sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg== + dependencies: + "@radix-ui/react-use-effect-event" "0.0.2" + "@radix-ui/react-use-layout-effect" "1.1.1" + +"@radix-ui/react-use-effect-event@0.0.2": + version "0.0.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz#090cf30d00a4c7632a15548512e9152217593907" + integrity sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA== + dependencies: + "@radix-ui/react-use-layout-effect" "1.1.1" + +"@radix-ui/react-use-layout-effect@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz#0c4230a9eed49d4589c967e2d9c0d9d60a23971e" + integrity sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ== + "@rolldown/binding-android-arm64@1.1.5": version "1.1.5" resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz#f58cb9a0a8128ed0582282720528547fc5c035f3" @@ -2389,6 +2571,11 @@ notepack.io "~3.0.1" uid2 "1.0.0" +"@standard-schema/spec@^1.0.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" + integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== + "@szmarczak/http-timer@^1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421" @@ -2928,7 +3115,7 @@ ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.3: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ajv@^8.6.0: +ajv@^8.12.0, ajv@^8.6.0: version "8.20.0" resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.20.0.tgz#304b3636add88ba7d936760dd50ece006dea95f9" integrity sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA== @@ -3288,6 +3475,11 @@ aws-sign2@~0.7.0: resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" integrity sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA== +aws-ssl-profiles@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz#157dd77e9f19b1d123678e93f120e6f193022641" + integrity sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g== + aws4@^1.8.0: version "1.13.2" resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.13.2.tgz#0aa167216965ac9474ccfa83892cfb6b3e1e52ef" @@ -3541,6 +3733,11 @@ bcrypt@6.0.0: node-addon-api "^8.3.0" node-gyp-build "^4.8.4" +better-result@^2.7.0: + version "2.9.2" + resolved "https://registry.yarnpkg.com/better-result/-/better-result-2.9.2.tgz#34da6e0e352bd44e4252acb88e2a4a35ec4c335e" + integrity sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q== + binary-extensions@^2.0.0: version "2.3.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" @@ -3711,6 +3908,24 @@ bytes@~3.1.2: resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== +c12@3.3.4: + version "3.3.4" + resolved "https://registry.yarnpkg.com/c12/-/c12-3.3.4.tgz#1253a5faf8b61244884d42459b4a6412571fe9f3" + integrity sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA== + dependencies: + chokidar "^5.0.0" + confbox "^0.2.4" + defu "^6.1.6" + dotenv "^17.3.1" + exsolve "^1.0.8" + giget "^3.2.0" + jiti "^2.6.1" + ohash "^2.0.11" + pathe "^2.0.3" + perfect-debounce "^2.1.0" + pkg-types "^2.3.0" + rc9 "^3.0.1" + cache-base@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" @@ -3852,6 +4067,13 @@ chardet@^0.7.0: resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== +chart.js@4.5.1: + version "4.5.1" + resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-4.5.1.tgz#19dd1a9a386a3f6397691672231cb5fc9c052c35" + integrity sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw== + dependencies: + "@kurkle/color" "^0.3.0" + cheerio-select@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/cheerio-select/-/cheerio-select-2.1.0.tgz#4d8673286b8126ca2a8e42740d5e3c4884ae21b4" @@ -3892,6 +4114,13 @@ chokidar@^3.2.2: optionalDependencies: fsevents "~2.3.2" +chokidar@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-5.0.0.tgz#949c126a9238a80792be9a0265934f098af369a5" + integrity sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw== + dependencies: + readdirp "^5.0.0" + ci-info@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" @@ -4122,6 +4351,11 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== +confbox@^0.2.4: + version "0.2.4" + resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.2.4.tgz#592e7be71f882a4a874e3c88f0ac1ef6f7da1ce5" + integrity sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ== + configstore@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/configstore/-/configstore-5.0.1.tgz#d365021b5df4b98cdd187d6a3b0e3f6a7cc5ed96" @@ -4538,6 +4772,11 @@ deep-is@~0.1.3: resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== +deepmerge-ts@7.1.5: + version "7.1.5" + resolved "https://registry.yarnpkg.com/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz#ff818564007f5c150808d2b7b732cac83aa415ab" + integrity sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw== + deepmerge@^4.2.2: version "4.3.1" resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" @@ -4588,12 +4827,17 @@ define-property@^2.0.2: is-descriptor "^1.0.2" isobject "^3.0.1" +defu@^6.1.6: + version "6.1.7" + resolved "https://registry.yarnpkg.com/defu/-/defu-6.1.7.tgz#72543567c8e9f97ff13ce402b6dbe09ac5ae4d23" + integrity sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ== + delayed-stream@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== -denque@2.1.0: +denque@2.1.0, denque@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/denque/-/denque-2.1.0.tgz#e93e1a6569fb5e66f16a3c2a2964617d349d6ab1" integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw== @@ -4613,6 +4857,11 @@ dequal@^2.0.3: resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== +destr@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/destr/-/destr-2.0.5.tgz#7d112ff1b925fb8d2079fac5bdb4a90973b51fdb" + integrity sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA== + destroy@1.2.0, destroy@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" @@ -4753,6 +5002,11 @@ dotenv@8.2.0: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.2.0.tgz#97e619259ada750eea3e4ea3e26bceea5424b16a" integrity sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw== +dotenv@^17.3.1: + version "17.4.2" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-17.4.2.tgz#c07e54a746e11eba021dd9e1047ced5afdc1c034" + integrity sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw== + dunder-proto@^1.0.0, dunder-proto@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" @@ -4780,6 +5034,14 @@ ee-first@1.1.1: resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== +effect@3.20.0: + version "3.20.0" + resolved "https://registry.yarnpkg.com/effect/-/effect-3.20.0.tgz#827752d2c90f0a12562f1fdac3bf0197d067fd6a" + integrity sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw== + dependencies: + "@standard-schema/spec" "^1.0.0" + fast-check "^3.23.1" + ejs@^3.1.10: version "3.1.10" resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.10.tgz#69ab8358b14e896f80cc39e62087b88500c3ac3b" @@ -4812,6 +5074,11 @@ emoji-regex@^8.0.0: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== +empathic@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/empathic/-/empathic-2.0.0.tgz#71d3c2b94fad49532ef98a6c34be0386659f6131" + integrity sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA== + enabled@1.0.x: version "1.0.2" resolved "https://registry.yarnpkg.com/enabled/-/enabled-1.0.2.tgz#965f6513d2c2d1c5f4652b64a2e3396467fc2f93" @@ -4873,6 +5140,11 @@ entities@^6.0.0: resolved "https://registry.yarnpkg.com/entities/-/entities-6.0.1.tgz#c28c34a43379ca7f61d074130b2f5f7020a30694" integrity sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g== +env-paths@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-3.0.0.tgz#2f1e89c2f6dbd3408e1b1711dd82d62e317f58da" + integrity sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A== + env-variable@0.0.x: version "0.0.6" resolved "https://registry.yarnpkg.com/env-variable/-/env-variable-0.0.6.tgz#74ab20b3786c545b62b4a4813ab8cf22726c9808" @@ -5564,6 +5836,11 @@ express@^4.22.2: utils-merge "1.0.1" vary "~1.1.2" +exsolve@^1.0.8: + version "1.1.0" + resolved "https://registry.yarnpkg.com/exsolve/-/exsolve-1.1.0.tgz#adefa9b18b3f3515e946d48eb2ca3bb0f2c51b4d" + integrity sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw== + extend-shallow@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" @@ -5617,6 +5894,13 @@ extsprintf@^1.2.0: resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.1.tgz#8d172c064867f235c0c84a596806d279bf4bcc07" integrity sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA== +fast-check@^3.23.1: + version "3.23.2" + resolved "https://registry.yarnpkg.com/fast-check/-/fast-check-3.23.2.tgz#0129f1eb7e4f500f58e8290edc83c670e4a574a2" + integrity sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A== + dependencies: + pure-rand "^6.1.0" + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -5770,7 +6054,7 @@ for-in@^1.0.2: resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" integrity sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ== -foreground-child@^3.3.1: +foreground-child@3.3.1, foreground-child@^3.3.1: version "3.3.1" resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== @@ -5889,6 +6173,13 @@ functions-have-names@^1.2.3: resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== +generate-function@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/generate-function/-/generate-function-2.3.1.tgz#f069617690c10c868e73b8465746764f97c3479f" + integrity sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ== + dependencies: + is-property "^1.0.2" + generator-function@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/generator-function/-/generator-function-2.0.1.tgz#0e75dd410d1243687a0ba2e951b94eedb8f737a2" @@ -5935,6 +6226,11 @@ get-package-type@^0.1.0: resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== +get-port-please@3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/get-port-please/-/get-port-please-3.2.0.tgz#0ce3cee194c448ac640ec39dc357a500f5d7d2bb" + integrity sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A== + get-proto@^1.0.0, get-proto@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" @@ -5988,6 +6284,11 @@ getpass@^0.1.1: dependencies: assert-plus "^1.0.0" +giget@^3.2.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/giget/-/giget-3.3.0.tgz#b70ee7a3c162c148867a8db918ee14d01b62dab7" + integrity sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw== + glob-parent@^5.0.0, glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" @@ -6075,6 +6376,16 @@ graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6 resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== +grammex@^3.1.11: + version "3.1.12" + resolved "https://registry.yarnpkg.com/grammex/-/grammex-3.1.12.tgz#08f021dd2cad009e64248fb53247cc6108788404" + integrity sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ== + +graphmatch@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/graphmatch/-/graphmatch-1.1.1.tgz#dcec68e8cb74de0a372d5252fc06e241daf71c38" + integrity sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg== + growly@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" @@ -6214,6 +6525,11 @@ hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react- dependencies: react-is "^16.7.0" +hono@^4.12.8: + version "4.12.28" + resolved "https://registry.yarnpkg.com/hono/-/hono-4.12.28.tgz#c2e12c32027f0e9fb8b0cef2ecfb73050c2ca62e" + integrity sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA== + hosted-git-info@^2.1.4: version "2.8.9" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" @@ -6299,6 +6615,11 @@ http-signature@~1.4.0: jsprim "^2.0.2" sshpk "^1.18.0" +http-status-codes@2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/http-status-codes/-/http-status-codes-2.3.0.tgz#987fefb28c69f92a43aecc77feec2866349a8bfc" + integrity sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA== + https-proxy-agent@^5.0.0: version "5.0.1" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" @@ -6340,6 +6661,13 @@ iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@~0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" +iconv-lite@^0.7.0: + version "0.7.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.7.3.tgz#84ee12f963e7de50bc01a13e160a078b3b0f415f" + integrity sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + idb@^7.0.1: version "7.1.1" resolved "https://registry.yarnpkg.com/idb/-/idb-7.1.1.tgz#d910ded866d32c7ced9befc5bfdf36f572ced72b" @@ -6809,6 +7137,11 @@ is-potential-custom-element-name@^1.0.1: resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== +is-property@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-property/-/is-property-1.0.2.tgz#57fe1c4e48474edd65b09911f26b1cd4095dda84" + integrity sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g== + is-regex@^1.0.5, is-regex@^1.1.0, is-regex@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.2.1.tgz#76d70a3ed10ef9be48eb577887d74205bf0cad22" @@ -7601,6 +7934,11 @@ jest@^26.6.0, jest@^26.6.3: import-local "^3.0.2" jest-cli "^26.6.3" +jiti@^2.6.1: + version "2.7.0" + resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.7.0.tgz#974228f2f4ca2bc21885a1797b45fea68e950c64" + integrity sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ== + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -8141,6 +8479,11 @@ logform@^2.1.1, logform@^2.7.0: safe-stable-stringify "^2.3.1" triple-beam "^1.3.0" +long@^5.2.1: + version "5.3.2" + resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83" + integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== + loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" @@ -8170,6 +8513,11 @@ lru-cache@^5.1.1: dependencies: yallist "^3.0.2" +lru.min@^1.0.0, lru.min@^1.1.0: + version "1.1.4" + resolved "https://registry.yarnpkg.com/lru.min/-/lru.min-1.1.4.tgz#6ea1737a8c1ba2300cc87ad46910a4bdffa0117b" + integrity sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA== + lz-string@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" @@ -8499,6 +8847,28 @@ mute-stream@0.0.8: resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== +mysql2@3.15.3: + version "3.15.3" + resolved "https://registry.yarnpkg.com/mysql2/-/mysql2-3.15.3.tgz#f0348d9c7401bb98cb1f45ffc5a773b109f70808" + integrity sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg== + dependencies: + aws-ssl-profiles "^1.1.1" + denque "^2.1.0" + generate-function "^2.3.1" + iconv-lite "^0.7.0" + long "^5.2.1" + lru.min "^1.0.0" + named-placeholders "^1.1.3" + seq-queue "^0.0.5" + sqlstring "^2.3.2" + +named-placeholders@^1.1.3: + version "1.1.6" + resolved "https://registry.yarnpkg.com/named-placeholders/-/named-placeholders-1.1.6.tgz#c50c6920b43f258f59c16add1e56654f5cc02bb5" + integrity sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w== + dependencies: + lru.min "^1.1.0" + nan@^2.12.1: version "2.28.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.28.0.tgz#126717fd359d5a03d3edf7c44e6ce9b707fb57f5" @@ -8787,6 +9157,11 @@ object.values@^1.1.0, object.values@^1.1.1, object.values@^1.1.6, object.values@ define-properties "^1.2.1" es-object-atoms "^1.0.0" +ohash@^2.0.11: + version "2.0.11" + resolved "https://registry.yarnpkg.com/ohash/-/ohash-2.0.11.tgz#60b11e8cff62ca9dee88d13747a5baa145f5900b" + integrity sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ== + on-finished@~2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" @@ -9137,6 +9512,11 @@ path-type@^4.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== +pathe@2.0.3, pathe@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" + integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== + pause@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/pause/-/pause-0.0.1.tgz#1d408b3fdb76923b9543d96fb4c9dfd535d9cb5d" @@ -9147,11 +9527,72 @@ pend@~1.2.0: resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg== +perfect-debounce@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/perfect-debounce/-/perfect-debounce-2.1.0.tgz#e7078e38f231cb191855c3136a4423aef725d261" + integrity sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g== + performance-now@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" integrity sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow== +pg-cloudflare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz#4b4c20e6d8ae531d400730f4804571a8d62f1497" + integrity sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A== + +pg-connection-string@^2.14.0: + version "2.14.0" + resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.14.0.tgz#abc26ee4f37c56c0f3ae0fcf0b0653cc4e1c0fd9" + integrity sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg== + +pg-int8@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" + integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw== + +pg-pool@^3.14.0: + version "3.14.0" + resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.14.0.tgz#f35ae4eb846780cad71af24099b3edfa9781ad90" + integrity sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw== + +pg-protocol@^1.15.0: + version "1.15.0" + resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.15.0.tgz#758f6c0679cc0bbf4938603b7597703f333180c0" + integrity sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ== + +pg-types@2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/pg-types/-/pg-types-2.2.0.tgz#2d0250d636454f7cfa3b6ae0382fdfa8063254a3" + integrity sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA== + dependencies: + pg-int8 "1.0.1" + postgres-array "~2.0.0" + postgres-bytea "~1.0.0" + postgres-date "~1.0.4" + postgres-interval "^1.1.0" + +pg@8.22.0: + version "8.22.0" + resolved "https://registry.yarnpkg.com/pg/-/pg-8.22.0.tgz#55ca3975026180c6dced6eec3a20a844c2dc9237" + integrity sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA== + dependencies: + pg-connection-string "^2.14.0" + pg-pool "^3.14.0" + pg-protocol "^1.15.0" + pg-types "2.2.0" + pgpass "1.0.5" + optionalDependencies: + pg-cloudflare "^1.4.0" + +pgpass@1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/pgpass/-/pgpass-1.0.5.tgz#9b873e4a564bb10fa7a7dbd55312728d422a223d" + integrity sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug== + dependencies: + split2 "^4.1.0" + picocolors@1.1.1, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" @@ -9189,6 +9630,15 @@ pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" +pkg-types@^2.3.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-2.3.1.tgz#fa27ed0940efcf40bba453b0e5cab41217b0d442" + integrity sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg== + dependencies: + confbox "^0.2.4" + exsolve "^1.0.8" + pathe "^2.0.3" + please-upgrade-node@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz#aeddd3f994c933e4ad98b99d9a556efa0e2fe942" @@ -9225,6 +9675,33 @@ postcss@^8.5.16: picocolors "^1.1.1" source-map-js "^1.2.1" +postgres-array@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postgres-array/-/postgres-array-2.0.0.tgz#48f8fce054fbc69671999329b8834b772652d82e" + integrity sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA== + +postgres-bytea@~1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/postgres-bytea/-/postgres-bytea-1.0.1.tgz#c40b3da0222c500ff1e51c5d7014b60b79697c7a" + integrity sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ== + +postgres-date@~1.0.4: + version "1.0.7" + resolved "https://registry.yarnpkg.com/postgres-date/-/postgres-date-1.0.7.tgz#51bc086006005e5061c591cee727f2531bf641a8" + integrity sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q== + +postgres-interval@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/postgres-interval/-/postgres-interval-1.2.0.tgz#b460c82cb1587507788819a06aa0fffdb3544695" + integrity sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ== + dependencies: + xtend "^4.0.0" + +postgres@3.4.7: + version "3.4.7" + resolved "https://registry.yarnpkg.com/postgres/-/postgres-3.4.7.tgz#122f460a808fe300cae53f592108b9906e625345" + integrity sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw== + prelude-ls@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" @@ -9291,6 +9768,18 @@ pretty-format@^27.0.2, pretty-format@^27.5.1: ansi-styles "^5.0.0" react-is "^17.0.1" +prisma@7.8.0: + version "7.8.0" + resolved "https://registry.yarnpkg.com/prisma/-/prisma-7.8.0.tgz#f64db69e59131fe10859efb5969af1c7e50e6620" + integrity sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw== + dependencies: + "@prisma/config" "7.8.0" + "@prisma/dev" "0.24.3" + "@prisma/engines" "7.8.0" + "@prisma/studio-core" "0.27.3" + mysql2 "3.15.3" + postgres "3.4.7" + process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" @@ -9351,6 +9840,15 @@ prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: object-assign "^4.1.1" react-is "^16.13.1" +proper-lockfile@4.1.2, proper-lockfile@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz#c8b9de2af6b2f1601067f98e01ac66baa223141f" + integrity sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA== + dependencies: + graceful-fs "^4.2.4" + retry "^0.12.0" + signal-exit "^3.0.2" + property-information@^5.3.0: version "5.6.0" resolved "https://registry.yarnpkg.com/property-information/-/property-information-5.6.0.tgz#61675545fb23002f245c6540ec46077d4da3ed69" @@ -9403,6 +9901,11 @@ pupa@^2.0.1: dependencies: escape-goat "^2.0.0" +pure-rand@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.1.0.tgz#d173cf23258231976ccbdb05247c9787957604f2" + integrity sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA== + qs@^6.15.2, qs@^6.9.6, qs@~6.15.1: version "6.15.3" resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.3.tgz#76852132a58ed5c7c0ef67e4441b9bb5d6061b3b" @@ -9461,6 +9964,14 @@ raw-body@~2.5.3: iconv-lite "~0.4.24" unpipe "~1.0.0" +rc9@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/rc9/-/rc9-3.0.1.tgz#3895e5834a2b5c2d8fb76d93e802fbcbc2579bc7" + integrity sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ== + dependencies: + defu "^6.1.6" + destr "^2.0.5" + rc@1.2.8, rc@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" @@ -9698,6 +10209,11 @@ readable-stream@^3.1.1, readable-stream@^3.6.2: string_decoder "^1.1.1" util-deprecate "^1.0.1" +readdirp@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-5.0.0.tgz#fbf1f71a727891d685bb1786f9ba74084f6e2f91" + integrity sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ== + readdirp@~3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" @@ -9857,6 +10373,11 @@ remark-rehype@^8.0.0: dependencies: mdast-util-to-hast "^10.2.0" +remeda@2.33.4: + version "2.33.4" + resolved "https://registry.yarnpkg.com/remeda/-/remeda-2.33.4.tgz#eae3bb2ec9795db58a1b66249913772a1a2c7989" + integrity sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ== + remove-trailing-separator@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" @@ -10033,6 +10554,11 @@ ret@~0.1.10: resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== +retry@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" + integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow== + rfdc@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.4.1.tgz#778f76c4fb731d93414e8f925fbecf64cce7f6ca" @@ -10185,7 +10711,7 @@ safe-stable-stringify@^2.3.1: resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz#4ca2f8e385f2831c432a719b108a3bf7af42a1dd" integrity sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA== -"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: +"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -10292,6 +10818,11 @@ send@~0.19.0, send@~0.19.1: range-parser "~1.2.1" statuses "~2.0.2" +seq-queue@^0.0.5: + version "0.0.5" + resolved "https://registry.yarnpkg.com/seq-queue/-/seq-queue-0.0.5.tgz#d56812e1c017a6e4e7c3e3a37a1da6d78dd3c93e" + integrity sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q== + serialize-javascript@^7.0.3: version "7.0.7" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-7.0.7.tgz#06ec40576d4cea96d68010a534520bff1f948a72" @@ -10677,11 +11208,21 @@ split-string@^3.0.1, split-string@^3.0.2: dependencies: extend-shallow "^3.0.0" +split2@^4.1.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/split2/-/split2-4.2.0.tgz#c9c5920904d148bab0b9f67145f245a86aadbfa4" + integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg== + sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== +sqlstring@^2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/sqlstring/-/sqlstring-2.3.3.tgz#2ddc21f03bce2c387ed60680e739922c65751d0c" + integrity sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg== + sshpk@^1.18.0, sshpk@^1.7.0: version "1.18.0" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.18.0.tgz#1663e55cddf4d688b86a46b77f0d5fe363aba028" @@ -10739,6 +11280,11 @@ statuses@~2.0.1, statuses@~2.0.2: resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== +std-env@3.10.0: + version "3.10.0" + resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.10.0.tgz#d810b27e3a073047b2b5e40034881f5ea6f9c83b" + integrity sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg== + stealthy-require@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b" @@ -11676,6 +12222,11 @@ v8-to-istanbul@^7.0.0: convert-source-map "^1.6.0" source-map "^0.7.3" +valibot@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/valibot/-/valibot-1.2.0.tgz#8fc720d9e4082ba16e30a914064a39619b2f1d6f" + integrity sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg== + validate-npm-package-license@^3.0.1: version "3.0.4" resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" @@ -12251,3 +12802,11 @@ yauzl@^3.3.1: integrity sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw== dependencies: pend "~1.2.0" + +zeptomatch@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/zeptomatch/-/zeptomatch-2.1.0.tgz#cca2cb2c61308d0c26f9689e6640f6335d0f2101" + integrity sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA== + dependencies: + grammex "^3.1.11" + graphmatch "^1.1.0"