diff --git a/.env.example b/.env.example index 318dcc9..4f826cd 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,12 @@ NODE_ENV=prod PORT=8080 SOCKETIO_PORT=9000 +# Preserve room membership while clients reconnect after a socket deploy. +ROOM_RECONNECT_GRACE_MS=60000 +# Grand Prix is intentionally disabled until the mode is redesigned. +GRAND_PRIX_ENABLED=false +# Manual Compose commands use this tag. scripts/deploy.sh overrides it with +# the full deployed commit SHA. APP_IMAGE_TAG=local # Public URLs used by the client build @@ -13,6 +19,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 +39,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/README_DEPLOYMENT.md b/README_DEPLOYMENT.md index eba0432..ce85f15 100644 --- a/README_DEPLOYMENT.md +++ b/README_DEPLOYMENT.md @@ -1,6 +1,6 @@ # LetsCube Deployment -This repo supports running LetsCube with Docker Compose on one VPS. The intended production stack is `api`, `socket`, `mongo`, `redis`, and `nginx`. +This repo supports running LetsCube with Docker Compose on one VPS. The intended production stack is `api`, `socket`, `mongo`, `postgres`, `redis`, and `nginx`. ## Current Startup Audit @@ -42,10 +42,16 @@ cp .env.example .env.prod Start or update production: ```bash -docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod up -d --build +APP_IMAGE_TAG="$(git rev-parse HEAD)" \ + docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod up -d --build ``` -The nginx container listens on ports `80` and `443`, proxies `/socket.io/` to `socket:9000`, and proxies everything else to `api:8080`. MongoDB and Redis are internal only. +Using the full commit SHA keeps manually deployed application images +immutable. `APP_IMAGE_TAG=local` remains available for direct development and +one-off Compose use. The deployment script always overrides `APP_IMAGE_TAG` +with the commit it checked out. + +The nginx container listens on ports `80` and `443`, proxies `/socket.io/` to `socket:9000`, and proxies everything else to `api:8080`. MongoDB, PostgreSQL, and Redis are internal only. TLS expects Let’s Encrypt files at: @@ -70,6 +76,7 @@ View logs: docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f api docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f socket docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f mongo +docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f postgres docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f redis docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f nginx ``` @@ -87,6 +94,117 @@ Deploy latest code from the server checkout: APP_DIR=/opt/letscube scripts/deploy.sh ``` +The default `DEPLOY_TARGET=auto` chooses the smallest safe application target: + +| Changed paths | Resolved target | +| --- | --- | +| Documentation, CI, or agent metadata only | `none` | +| Browser client only | `api` (which also serves the static client) | +| Shared socket protocol, server, dependency, container, deployment, or unknown files | `all` | + +Override the classifier when the intended runtime impact is known: + +```bash +DEPLOY_TARGET=api APP_DIR=/opt/letscube scripts/deploy.sh +DEPLOY_TARGET=socket APP_DIR=/opt/letscube scripts/deploy.sh +DEPLOY_TARGET=all APP_DIR=/opt/letscube scripts/deploy.sh +DEPLOY_TARGET=none APP_DIR=/opt/letscube scripts/deploy.sh +``` + +Accepted values are `auto`, `api`, `socket`, `all`, and `none`. Explicit +targets are honored even when the checkout is already current. With `auto`, an +unchanged checkout exits without replacing healthy running containers. If API, +socket, or nginx is missing, `auto` bootstraps both application services; +`DEPLOY_FORCE=true` also rebuilds and replaces both at the current commit. +Readiness waits are capped at 60 seconds before automatic rollback; override +that bound with `DEPLOY_WAIT_TIMEOUT_SECONDS` when diagnosing unusually slow +startup. + +You can verify the conservative path classifier without fetching code or +touching Docker: + +```bash +scripts/test-deploy-classifier.sh +scripts/test-deploy.sh +scripts/classify-deploy-target.sh client/src/App.jsx +``` + +For any selected application target, the deploy script builds one shared image +tagged with the full commit SHA, waits for backing services without recreating +them, and applies PostgreSQL migrations before replacing application +containers. It snapshots the exact image ID of every selected running service, +then replaces and health-checks services one at a time. For `all`, socket rolls +out before API/static so the backward-compatible server is ready before the new +browser client is served. nginx is gracefully reloaded only after a selected +service changes, so it resolves the replacement container without dropping +unrelated connections. If nginx is not running during a bootstrap deploy, its +startup is deferred until the selected application services are ready. + +If replacement readiness or the nginx reload fails, recent diagnostic logs are +printed and every application service attempted in that deploy is restored in +reverse order using its exact pre-deploy image and Compose configuration. nginx +is then reloaded again. +Rollback image tags are retained locally for manual recovery until normal image +cleanup. Database migrations are not automatically reversed, so migrations +must remain backward-compatible with the previous application image. + +The checkout remains at the fetched commit after a failed deploy. Correct the +failure and rerun with the explicit target printed by the script; a later +`auto` run with no new commit otherwise treats the checkout as up to date. + +Socket clients briefly reconnect only when `socket` is selected. Room +membership, the current scramble, waiting/competing state, and room admin are +preserved during `ROOM_RECONNECT_GRACE_MS` (60 seconds by default). A client +that reconnects within that window resumes the existing room without being +logically removed first. Only users with no socket on any Socket.IO instance +after the grace window are removed from the room. + +An in-progress solve continues timing in the browser during that reconnect. If +the solve finishes before the socket is ready, the result is retained locally, +the next solve is blocked, and the saved result is submitted only after the +client rejoins the room. The local copy is cleared only after the server +acknowledges persistence, so a refresh or repeated reconnect does not discard +the time. + +Set the grace window in `.env.prod` if production deploys or client reconnects +need more time: + +```bash +ROOM_RECONNECT_GRACE_MS=90000 +``` + +Keep the grace shorter than the time in which an abandoned room should appear +active. Explicit leaves, kicks, and bans remain immediate and do not use the +grace window. + +Grand Prix is intentionally disabled in production: + +```bash +GRAND_PRIX_ENABLED=false +``` + +Keep this setting disabled until the legacy scheduler is redesigned. Setting +it to `true` explicitly restores the old mode for development or controlled +testing. + +### Health Checks + +The API and socket processes expose dependency-aware health endpoints. +Unavailable MongoDB or Redis returns HTTP `503` with the failing check marked +`error`. PostgreSQL dual writes are optional, so a PostgreSQL outage returns +HTTP `200` with overall status `degraded` while the primary MongoDB/Redis path +remains ready. + +```bash +curl -sS https://letscube.net/health/api +curl -sS https://letscube.net/health/socket +``` + +The default Socket.IO namespace also accepts a `health_check` event. It returns +the socket health report through the acknowledgment callback, or emits +`health_status` when no callback is provided. This tests a real Socket.IO +connection in addition to the HTTP readiness endpoint. + ## Backups And Restore Run a MongoDB backup: @@ -122,10 +240,10 @@ Do not upgrade the old VPS in place for this migration. 5. Clone this repo to `/opt/letscube`. 6. Create `.env.prod` with production secrets and domains. 7. Copy or issue Let’s Encrypt certs for `letscube.net`. -8. Start MongoDB and Redis containers. +8. Start MongoDB, PostgreSQL, and Redis containers. 9. Restore MongoDB from the production backup. 10. Start the full production stack. 11. Test frontend loading, login, room create/join, race/session flows, Redis-backed socket behavior, backups, and restore on non-production data. -12. Confirm MongoDB and Redis are not publicly reachable. +12. Confirm MongoDB, PostgreSQL, and Redis are not publicly reachable. 13. Point DNS at the new VPS. 14. Keep the old VPS available for rollback until the new stack has been stable. 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/components/Navigation.jsx b/client/src/components/Navigation.jsx index d9b849f..7e84bd5 100644 --- a/client/src/components/Navigation.jsx +++ b/client/src/components/Navigation.jsx @@ -1,9 +1,13 @@ import React, { Suspense, lazy } from 'react'; import PropTypes from 'prop-types'; -import { Switch, Route, Redirect } from 'react-router-dom'; +import { + Switch, Route, Redirect, useLocation, +} from 'react-router-dom'; import { connect, useDispatch } from 'react-redux'; +import { push } from 'connected-react-router'; // import Backdrop from '@material-ui/core/Backdrop'; import CircularProgress from '@material-ui/core/CircularProgress'; +import Button from '@material-ui/core/Button'; import Snackbar from '@material-ui/core/Snackbar'; import Alert from '@material-ui/lab/Alert'; import { makeStyles } from '@material-ui/core/styles'; @@ -13,12 +17,97 @@ import Footer from './Footer'; import WCARedirect from './WCARedirect'; import Admin from './Admin'; import { closeMessage } from '../store/messages/actions'; +import { discardPendingResult } from '../store/room/actions'; +import { + canDiscardPendingResult, + isPendingResult, + pendingResultBelongsToUser, +} from '../store/room/resultOutbox'; import Text from './Text'; const Lobby = lazy(() => import('./Lobby/index')); const Room = lazy(() => import('./Room/index')); const Profile = lazy(() => import('./common/Profile')); +export const shouldShowGlobalPendingResult = (room, pendingResult) => ( + isPendingResult(pendingResult) + && !(room.accessCode && room.type === 'normal' && !room.fetching) +); + +export function GlobalPendingResultAlert({ + atPendingRoom, + error, + onDiscard, + onReturn, + pendingResult, + status, + userId, +}) { + const belongsToUser = pendingResultBelongsToUser(pendingResult, userId); + const canDiscardResult = canDiscardPendingResult(pendingResult, status); + let message; + + if (!belongsToUser) { + message = canDiscardResult + ? 'A saved time for another account is stored on this device. Switch back to that account or discard it.' + : 'A saved time for another account may already be submitting. Switch back to that account to finish it.'; + } else if (status === 'failed') { + message = `Your saved time could not be submitted: ${error.message}`; + } else if (atPendingRoom) { + message = canDiscardResult + ? 'Your time is still saved on this device, but its room is not currently joined. Restore access to the room or discard the saved result.' + : 'Your time may already be submitting, but its room is not currently joined. Restore access to the room to finish it.'; + } else { + message = canDiscardResult + ? `Your saved time is waiting in room ${pendingResult.roomId}. Return there to submit it, or discard it.` + : `Your saved time is waiting in room ${pendingResult.roomId}. Return there to finish submitting it.`; + } + + return ( + + {belongsToUser && !atPendingRoom && ( + + )} + {canDiscardResult && ( + + )} + + )} + > + {message} + + ); +} + +GlobalPendingResultAlert.propTypes = { + atPendingRoom: PropTypes.bool.isRequired, + error: PropTypes.shape({ + message: PropTypes.string, + }), + onDiscard: PropTypes.func.isRequired, + onReturn: PropTypes.func.isRequired, + pendingResult: PropTypes.shape({ + deliveryAttempted: PropTypes.bool, + roomId: PropTypes.string, + submissionId: PropTypes.string, + userId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + }).isRequired, + status: PropTypes.oneOf(['pending', 'sending', 'failed']).isRequired, + userId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), +}; + +GlobalPendingResultAlert.defaultProps = { + error: null, + userId: undefined, +}; + const useStyles = makeStyles((theme) => ({ root: { display: 'flex', @@ -46,10 +135,20 @@ const useStyles = makeStyles((theme) => ({ })); function Navigation({ - room, connected, user, messages, server, + room, roomList, connected, user, messages, server, }) { const classes = useStyles(); const dispatch = useDispatch(); + const location = useLocation(); + const roomConnectionInterrupted = !!room._id && !roomList.connected; + const connectionInterrupted = room._id + ? roomConnectionInterrupted + : (!connected || server.reconnecting); + const resultSubmission = room.resultSubmission || {}; + const pendingResult = resultSubmission.pendingResult; + const showGlobalPendingResult = shouldShowGlobalPendingResult(room, pendingResult); + const pendingRoomPath = pendingResult ? `/rooms/${pendingResult.roomId}` : null; + const atPendingRoom = location.pathname === pendingRoomPath; const handleClose = (index, event, reason) => { if (reason === 'clickaway') { @@ -82,9 +181,11 @@ function Navigation({
- {(!connected || server.reconnecting) && ( - - Disconnected from server. + {connectionInterrupted && ( + + {roomConnectionInterrupted + ? 'Room connection interrupted. You can finish your solve; your completed time will be saved on this device.' + : 'Disconnected from server.'} { (server.reconnecting || server.reconnectAttempts > 0) && ( ` Reconnecting...${server.reconnectAttempts} attempts` )} @@ -99,6 +200,17 @@ function Navigation({ )} )} + {showGlobalPendingResult && ( + dispatch(discardPendingResult(pendingResult.submissionId))} + onReturn={() => dispatch(push(pendingRoomPath))} + pendingResult={pendingResult} + status={resultSubmission.status} + userId={user.id} + /> + )}
{!user.fetching && ( @@ -145,6 +257,23 @@ Navigation.propTypes = { })), room: PropTypes.shape({ _id: PropTypes.string, + accessCode: PropTypes.string, + fetching: PropTypes.bool, + type: PropTypes.string, + resultSubmission: PropTypes.shape({ + status: PropTypes.oneOf(['idle', 'pending', 'sending', 'failed']), + pendingResult: PropTypes.shape({ + deliveryAttempted: PropTypes.bool, + roomId: PropTypes.string, + submissionId: PropTypes.string, + }), + error: PropTypes.shape({ + message: PropTypes.string, + }), + }), + }), + roomList: PropTypes.shape({ + connected: PropTypes.bool, }), server: PropTypes.shape({ reconnecting: PropTypes.bool, @@ -161,6 +290,9 @@ Navigation.defaultProps = { room: { id: undefined, }, + roomList: { + connected: false, + }, server: { reconnecting: false, reconnectAttempts: 0, @@ -174,6 +306,7 @@ const mapStateToProps = (state) => ({ user: state.user, messages: state.messages.messages, room: state.room, + roomList: state.roomList, }); export default connect(mapStateToProps)(Navigation); diff --git a/client/src/components/Navigation.test.jsx b/client/src/components/Navigation.test.jsx new file mode 100644 index 0000000..6dd8103 --- /dev/null +++ b/client/src/components/Navigation.test.jsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import Alert from '@material-ui/lab/Alert'; +import Button from '@material-ui/core/Button'; +import { + GlobalPendingResultAlert, + shouldShowGlobalPendingResult, +} from './Navigation'; +import { + createPendingResult, + markPendingResultAttempted, +} from '../store/room/resultOutbox'; + +const pendingResult = createPendingResult({ + userId: 42, + roomId: 'original-room', + attemptId: 7, + attemptKey: 'attempt-seven', + result: { time: 1234, penalties: {} }, +}, { + createId: () => 'submission-one', + now: () => 1000, +}); + +describe('global pending result alert', () => { + it('is hidden when a joined normal room can render the room-level controls', () => { + expect(shouldShowGlobalPendingResult({ + accessCode: 'ABC123', + fetching: false, + type: 'normal', + }, pendingResult)).toBe(false); + + expect(shouldShowGlobalPendingResult({ + accessCode: 'ABC123', + fetching: true, + type: 'normal', + }, pendingResult)).toBe(true); + }); + + it('keeps discard accessible when the pending room could not be joined', () => { + const onDiscard = jest.fn(); + const wrapper = shallow( + , + ); + const actions = shallow(
{wrapper.find(Alert).prop('action')}
); + + expect(wrapper.text()).toContain('room is not currently joined'); + expect(actions.find(Button)).toHaveLength(1); + expect(actions.find(Button).text()).toBe('Discard saved result'); + + actions.find(Button).simulate('click'); + expect(onDiscard).toHaveBeenCalledTimes(1); + }); + + it('offers a return action when the user is somewhere else', () => { + const onReturn = jest.fn(); + const wrapper = shallow( + , + ); + const actions = shallow(
{wrapper.find(Alert).prop('action')}
); + + expect(actions.find(Button).map((button) => button.text())).toEqual([ + 'Return to room', + 'Discard saved result', + ]); + actions.find(Button).at(0).simulate('click'); + expect(onReturn).toHaveBeenCalledTimes(1); + }); + + it('does not offer discard after delivery has started', () => { + const wrapper = shallow( + , + ); + const actions = shallow(
{wrapper.find(Alert).prop('action')}
); + + expect(actions.find(Button)).toHaveLength(1); + expect(actions.find(Button).text()).toBe('Return to room'); + expect(wrapper.text()).toContain('finish submitting'); + }); +}); diff --git a/client/src/components/Room/Common/Main.jsx b/client/src/components/Room/Common/Main.jsx index 4a09c00..7cdadda 100644 --- a/client/src/components/Room/Common/Main.jsx +++ b/client/src/components/Room/Common/Main.jsx @@ -6,16 +6,26 @@ import Grid from '@material-ui/core/Grid'; import Paper from '@material-ui/core/Paper'; import Divider from '@material-ui/core/Divider'; import Typography from '@material-ui/core/Typography'; +import Button from '@material-ui/core/Button'; import ClickAwayListener from '@material-ui/core/ClickAwayListener'; +import Alert from '@material-ui/lab/Alert'; import { Cube } from 'react-cube-svg'; import UIfx from 'uifx'; +import { push } from 'connected-react-router'; import notificationAsset from '../../../assets/notification.mp3'; import calcStats from '../../../lib/stats'; import { submitResult, + discardPendingResult, sendStatus, timerFocused, } from '../../../store/room/actions'; +import { + canDiscardPendingResult, + isPendingResult, + pendingResultBelongsToUser, + pendingResultMatches, +} from '../../../store/room/resultOutbox'; import { StatsDialogProvider } from './StatsDialogProvider'; import { EditDialogProvider } from './EditDialogProvider'; import TimesTable from './TimesTable'; @@ -36,6 +46,9 @@ const useStyles = withStyles((theme) => ({ waitingForBox: { padding: '.5em', }, + submissionAlert: { + borderRadius: 0, + }, scrambleBox: { padding: '.5em', textAlign: 'center', @@ -45,12 +58,12 @@ const useStyles = withStyles((theme) => ({ }, })); -class Main extends React.Component { +export class Main extends React.Component { constructor(props) { super(props); this.state = { - currentAttemptId: undefined, + currentAttempt: undefined, }; } @@ -68,26 +81,27 @@ class Main extends React.Component { onSubmitTime(event) { const { dispatch, room, user } = this.props; - const { currentAttemptId } = this.state; + const { currentAttempt } = this.state; if (!room.attempts.length) { return; } - // Don't even bother sending the result. if (!user.id) { return; } const latestAttempt = room.attempts ? room.attempts[room.attempts.length - 1] : {}; + const submittedAttempt = currentAttempt || latestAttempt; dispatch(submitResult({ - id: currentAttemptId || latestAttempt.id, + id: submittedAttempt.id, + attemptKey: submittedAttempt._id, result: { time: event.time, penalties: event.penalties, }, })); - this.setState({ currentAttemptId: null }); + this.setState({ currentAttempt: null }); } onTimerFocused = () => { @@ -108,28 +122,89 @@ class Main extends React.Component { handlePriming() { const { room } = this.props; const latestAttempt = room.attempts ? room.attempts[room.attempts.length - 1] : {}; - this.setState({ currentAttemptId: latestAttempt.id }); + this.setState({ currentAttempt: latestAttempt }); } render() { const { - classes, dispatch, room, user, onlyShowSelf, + classes, dispatch, room, user, onlyShowSelf, roomConnected, } = this.props; const { users, attempts, waitingFor, } = room; + const resultSubmission = room.resultSubmission || {}; + const pendingResult = resultSubmission.pendingResult; + const hasPendingResult = isPendingResult(pendingResult); + const canDiscardResult = canDiscardPendingResult( + pendingResult, + resultSubmission.status, + ); + const pendingBelongsToUser = pendingResultBelongsToUser(pendingResult, user.id); + const pendingMatchesRoom = pendingResultMatches(pendingResult, { + userId: user.id, + roomId: room._id, + }); const latestAttempt = (attempts && attempts.length) ? attempts[attempts.length - 1] : {}; const timerDisabled = !room.timerFocused || !room.competing[user.id] - || !room.waitingFor[user.id]; + || !room.waitingFor[user.id] || hasPendingResult; const hidden = room.competing[user.id] && !waitingFor[user.id]; + let submissionMessage = ''; + if (!pendingBelongsToUser) { + submissionMessage = canDiscardResult + ? 'A saved time for another account is stored on this device. Switch back to that account or discard it before timing another solve.' + : 'A saved time for another account may already be submitting. Switch back to that account to finish it.'; + } else if (!pendingMatchesRoom) { + submissionMessage = canDiscardResult + ? `Your saved time belongs to room ${pendingResult.roomId}. Return there to submit it, or discard it before timing another solve.` + : `Your saved time belongs to room ${pendingResult.roomId}. Return there to finish submitting it.`; + } else if (resultSubmission.status === 'failed') { + submissionMessage = `Your saved time could not be submitted: ${resultSubmission.error.message}`; + } else if (resultSubmission.status === 'sending') { + submissionMessage = 'Submitting your saved time...'; + } else if (roomConnected) { + submissionMessage = 'Your time is saved on this device and waiting to submit.'; + } else { + submissionMessage = 'Your time is saved on this device. It will submit after the room reconnects.'; + } + const stats = calcStats(attempts, users); const showScramble = latestAttempt.scrambles && room.event === '333'; return ( { this.onTimerDefocused(); }}> { this.onTimerFocused(); }}> + {hasPendingResult && ( + + {pendingBelongsToUser && !pendingMatchesRoom && ( + + )} + {canDiscardResult && ( + + )} + + )} + > + {submissionMessage} + + )}
@@ -231,12 +306,23 @@ Main.propTypes = { waitingFor: PropTypes.shape(), statuses: PropTypes.shape(), attempts: PropTypes.arrayOf(PropTypes.shape({ + _id: PropTypes.string, id: PropTypes.number, })), admin: PropTypes.shape({ id: PropTypes.number, }), timerFocused: PropTypes.bool, + resultSubmission: PropTypes.shape({ + status: PropTypes.oneOf(['idle', 'pending', 'sending', 'failed']), + pendingResult: PropTypes.shape({ + deliveryAttempted: PropTypes.bool, + submissionId: PropTypes.string, + }), + error: PropTypes.shape({ + message: PropTypes.string, + }), + }), }), user: PropTypes.shape({ id: PropTypes.number, @@ -247,6 +333,7 @@ Main.propTypes = { dispatch: PropTypes.func.isRequired, classes: PropTypes.shape().isRequired, onlyShowSelf: PropTypes.bool, + roomConnected: PropTypes.bool, }; Main.defaultProps = { @@ -264,6 +351,11 @@ Main.defaultProps = { id: undefined, }, timerFocused: true, + resultSubmission: { + status: 'idle', + pendingResult: null, + error: null, + }, }, user: { id: undefined, @@ -272,10 +364,12 @@ Main.defaultProps = { timerType: 'spacebar', }, onlyShowSelf: false, + roomConnected: false, }; const mapStateToProps = (state) => ({ room: state.room, + roomConnected: state.roomList.connected, user: state.user, }); diff --git a/client/src/components/Room/Common/Main.test.jsx b/client/src/components/Room/Common/Main.test.jsx new file mode 100644 index 0000000..4f28d8b --- /dev/null +++ b/client/src/components/Room/Common/Main.test.jsx @@ -0,0 +1,180 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import Alert from '@material-ui/lab/Alert'; +import Button from '@material-ui/core/Button'; +import Timer from '../../Timer/index'; +import { Main } from './Main'; +import { + createPendingResult, + markPendingResultAttempted, +} from '../../../store/room/resultOutbox'; +import { + DISCARD_PENDING_RESULT, + SUBMIT_RESULT, +} from '../../../store/room/actions'; + +jest.mock('../../Timer/StackmatTimer', () => () => null); +jest.mock('react-cube-svg', () => ({ Cube: () => null })); + +const pendingResult = createPendingResult({ + userId: 42, + roomId: 'original-room', + attemptId: 7, + attemptKey: 'attempt-seven', + result: { time: 1234, penalties: {} }, +}, { + createId: () => 'submission-one', + now: () => 1000, +}); + +const makeProps = (overrides = {}) => ({ + classes: { + root: 'root', + submissionAlert: 'submissionAlert', + scrambleBox: 'scrambleBox', + waitingForBox: 'waitingForBox', + }, + dispatch: jest.fn(), + onlyShowSelf: false, + roomConnected: true, + user: { + id: 42, + useInspection: false, + muteTimer: true, + timerType: 'spacebar', + }, + room: { + _id: 'current-room', + event: '333', + users: [{ id: 42 }], + competing: { 42: true }, + waitingFor: { 42: true }, + attempts: [{ _id: 'attempt-eight', id: 8, scrambles: ['R U'], results: {} }], + timerFocused: true, + resultSubmission: { + status: 'pending', + pendingResult, + error: null, + }, + }, + ...overrides, +}); + +describe('room pending result UX', () => { + it('submits against the immutable attempt that was primed', () => { + const props = makeProps({ + room: { + ...makeProps().room, + attempts: [{ + _id: 'primed-attempt', id: 0, scrambles: ['R U'], results: {}, + }], + resultSubmission: { + status: 'idle', + pendingResult: null, + error: null, + }, + }, + }); + const wrapper = shallow(
); + wrapper.instance().handlePriming(); + wrapper.setProps({ + room: { + ...props.room, + attempts: [{ + _id: 'replacement-attempt', id: 0, scrambles: ['F R'], results: {}, + }], + }, + }); + + wrapper.instance().onSubmitTime({ time: 1234, penalties: {} }); + + expect(props.dispatch).toHaveBeenCalledWith({ + type: SUBMIT_RESULT, + result: { + id: 0, + attemptKey: 'primed-attempt', + result: { time: 1234, penalties: {} }, + }, + }); + }); + + it('does not disable the timer merely because the room socket disconnected', () => { + const props = makeProps({ + roomConnected: false, + room: { + ...makeProps().room, + resultSubmission: { + status: 'idle', + pendingResult: null, + error: null, + }, + }, + }); + const wrapper = shallow(
); + + expect(wrapper.find(Timer).prop('disabled')).toBe(false); + expect(wrapper.find(Alert)).toHaveLength(0); + }); + + it('blocks another solve and offers return/discard actions outside the original room', () => { + const props = makeProps(); + const wrapper = shallow(
); + const actions = shallow(
{wrapper.find(Alert).prop('action')}
); + + expect(wrapper.find(Timer).prop('disabled')).toBe(true); + expect(wrapper.find(Alert).text()).toContain('Your saved time belongs to room original-room.'); + expect(actions.find(Button).map((button) => button.text())).toEqual([ + 'Return to room', + 'Discard saved result', + ]); + + actions.find(Button).at(1).simulate('click'); + expect(props.dispatch).toHaveBeenCalledWith({ + type: DISCARD_PENDING_RESULT, + submissionId: 'submission-one', + }); + }); + + it('also blocks a solve when the device outbox belongs to another account', () => { + const anotherUsersResult = { + ...pendingResult, + userId: 99, + }; + const props = makeProps({ + room: { + ...makeProps().room, + resultSubmission: { + status: 'pending', + pendingResult: anotherUsersResult, + error: null, + }, + }, + }); + const wrapper = shallow(
); + const actions = shallow(
{wrapper.find(Alert).prop('action')}
); + + expect(wrapper.find(Timer).prop('disabled')).toBe(true); + expect(wrapper.find(Alert).text()).toContain('another account'); + expect(actions.find(Button)).toHaveLength(1); + expect(actions.find(Button).text()).toBe('Discard saved result'); + }); + + it('keeps an attempted result without offering discard', () => { + const props = makeProps({ + room: { + ...makeProps().room, + resultSubmission: { + status: 'pending', + pendingResult: markPendingResultAttempted(pendingResult), + error: null, + }, + }, + }); + const wrapper = shallow(
); + const actions = shallow(
{wrapper.find(Alert).prop('action')}
); + + expect(actions.find(Button)).toHaveLength(1); + expect(actions.find(Button).text()).toBe('Return to room'); + expect(wrapper.find(Alert).text()).toContain('finish submitting'); + }); +}); diff --git a/client/src/components/Room/GrandPrix/GrandPrixMain.jsx b/client/src/components/Room/GrandPrix/GrandPrixMain.jsx index 7bc2331..1a656ac 100644 --- a/client/src/components/Room/GrandPrix/GrandPrixMain.jsx +++ b/client/src/components/Room/GrandPrix/GrandPrixMain.jsx @@ -19,6 +19,7 @@ import { toggleFollowUser, } from '../../../store/room/actions'; import { getRegisteredUsers } from '../../../store/room/selectors'; +import { isPendingResult } from '../../../store/room/resultOutbox'; import { StatsDialogProvider } from '../Common/StatsDialogProvider'; import { EditDialogProvider } from '../Common/EditDialogProvider'; import TimesTable from '../Common/TimesTable'; @@ -184,7 +185,7 @@ function Main({ room, user }) { const classes = useStyles(); const dispatch = useDispatch(); const theme = useTheme(); - const [currentAttemptId, setCurrentAttemptId] = useState(undefined); + const [currentAttempt, setCurrentAttempt] = useState(undefined); const [followUserDialogOpen, setFollowUserDialogOpen] = useState(false); const { @@ -204,14 +205,16 @@ function Main({ room, user }) { } const latestAttempt = room.attempts ? room.attempts[room.attempts.length - 1] : {}; + const submittedAttempt = currentAttempt || latestAttempt; dispatch(submitResult({ - id: currentAttemptId || latestAttempt.id, + id: submittedAttempt.id, + attemptKey: submittedAttempt._id, result: { time: event.time, penalties: event.penalties, }, })); - setCurrentAttemptId(null); + setCurrentAttempt(null); }; const onTimerFocused = () => { @@ -228,7 +231,7 @@ function Main({ room, user }) { const handlePriming = () => { const latestAttempt = room.attempts ? room.attempts[room.attempts.length - 1] : {}; - setCurrentAttemptId(latestAttempt.id); + setCurrentAttempt(latestAttempt); }; const handleToggleUserFollow = (userId) => { @@ -237,7 +240,8 @@ function Main({ room, user }) { const latestAttempt = attempts && attempts.length && attempts[attempts.length - 1]; const showScrambleBox = latestAttempt && !latestAttempt.results[user.id]; - const timerDisabled = !room.timerFocused || !room.competing[user.id] || !showScrambleBox; + const timerDisabled = !room.timerFocused || !room.competing[user.id] || !showScrambleBox + || isPendingResult(room.resultSubmission && room.resultSubmission.pendingResult); const stats = calcStats(attempts, users); const showScramble = latestAttempt.scrambles && room.event === '333'; @@ -369,8 +373,14 @@ Main.propTypes = { statuses: PropTypes.shape(), registered: PropTypes.shape(), attempts: PropTypes.arrayOf(PropTypes.shape({ + _id: PropTypes.string, id: PropTypes.number, })), + resultSubmission: PropTypes.shape({ + pendingResult: PropTypes.shape({ + submissionId: PropTypes.string, + }), + }), admin: PropTypes.shape({ id: PropTypes.number, }), @@ -399,6 +409,9 @@ Main.defaultProps = { statues: {}, registered: {}, attempts: [], + resultSubmission: { + pendingResult: null, + }, admin: { id: undefined, }, diff --git a/client/src/components/Timer/ManualTimer.jsx b/client/src/components/Timer/ManualTimer.jsx index f9cef7a..4b69a6d 100644 --- a/client/src/components/Timer/ManualTimer.jsx +++ b/client/src/components/Timer/ManualTimer.jsx @@ -60,7 +60,7 @@ const STATUS = { const STATUSES = [STATUS.RESTING, STATUS.INSPECTING, STATUS.TIMING, STATUS.SUBMITTING]; -class ManualTimer extends React.Component { +export class ManualTimer extends React.Component { constructor(props) { super(props); @@ -194,10 +194,15 @@ class ManualTimer extends React.Component { const { onSubmitTime } = this.props; const { penalties } = this.state; + const time = this.finalTime(); + + if (!Number.isFinite(time)) { + return; + } if (onSubmitTime) { onSubmitTime({ - time: this.finalTime(), + time, penalties: { DNF: penalties.DNF, inspection: penalties.inspection, diff --git a/client/src/components/Timer/ManualTimer.test.jsx b/client/src/components/Timer/ManualTimer.test.jsx new file mode 100644 index 0000000..a149cb2 --- /dev/null +++ b/client/src/components/Timer/ManualTimer.test.jsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { ManualTimer } from './ManualTimer'; + +const makeProps = () => ({ + classes: { + root: 'root', + input: 'input', + inputProps: 'inputProps', + penaltyBox: 'penaltyBox', + }, + onPriming: jest.fn(), + onStatusChange: jest.fn(), + onSubmitTime: jest.fn(), + useInspection: false, +}); + +describe('manual timer submission', () => { + it.each(['', 'not-a-time'])('keeps invalid input for correction: %p', (timeInput) => { + const props = makeProps(); + const wrapper = shallow(); + wrapper.setState({ timeInput }); + + wrapper.instance().submitTime({ preventDefault: jest.fn() }); + + expect(props.onSubmitTime).not.toHaveBeenCalled(); + expect(wrapper.state('timeInput')).toBe(timeInput); + }); + + it('submits and resets a valid time', () => { + const props = makeProps(); + const wrapper = shallow(); + wrapper.setState({ timeInput: '5.00' }); + + wrapper.instance().submitTime({ preventDefault: jest.fn() }); + + expect(props.onSubmitTime).toHaveBeenCalledWith({ + time: 5000, + penalties: { + AUF: false, + DNF: false, + inspection: false, + }, + }); + expect(wrapper.state('timeInput')).toBe(''); + }); +}); diff --git a/client/src/components/Timer/StackmatTimer.jsx b/client/src/components/Timer/StackmatTimer.jsx index 039ec65..e1b2045 100644 --- a/client/src/components/Timer/StackmatTimer.jsx +++ b/client/src/components/Timer/StackmatTimer.jsx @@ -228,6 +228,10 @@ class Timer extends React.Component { setStatus (status) { this.setState({ status }); + if (status === STATUS.TIMING) { + this.props.onPriming(); + } + if (STATUSES.indexOf(status) > -1) { this.props.onStatusChange(status); } 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..563ae31 --- /dev/null +++ b/client/src/lib/protocol.json @@ -0,0 +1,50 @@ +{ + "MESSAGE": "message", + "CONNECT": "connect", + "DISCONNECT": "disconnect", + "CONNECT_ERR": "connect_error", + "RECONNECT_ERR": "reconnect_error", + "RECONNECT": "reconnect", + "HEALTH_CHECK": "health_check", + "HEALTH_STATUS": "health_status", + "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..f65f321 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 { @@ -21,8 +21,8 @@ import { START_ROOM, PAUSE_ROOM, UPDATE_USER, + DISCARD_PENDING_RESULT, joinRoom, - // leaveRoom, roomUpdated, resetRoom, userJoined, @@ -34,7 +34,21 @@ import { updateAdmin, updateCompetingForUser, nextSolveAt, + resultSubmissionPending, + resultSubmissionSending, + resultSubmissionFailed, + resultSubmissionCleared, } from '../room/actions'; +import { + canDiscardPendingResult, + clearPendingResult, + createPendingResult, + markPendingResultAttempted, + markPendingResultFailed, + pendingResultMatches, + persistPendingResult, + readPendingResult, +} from '../room/resultOutbox'; import { ROOMS_CONNECT, ROOMS_DISCONNECT, @@ -57,29 +71,273 @@ import { } from '../admin/actions'; import { manager } from './manager'; -const roomsNamespaceMiddleware = (store) => { +const DEFAULT_ACK_TIMEOUT_MS = 10000; +const DEFAULT_RETRY_DELAY_MS = 2000; + +export const createRoomsNamespaceMiddleware = ({ + NamespaceClass = Namespace, + namespaceManager = manager, + storage = window.localStorage, + createSubmissionId = uuid, + now = Date.now, + setTimeoutFn = window.setTimeout.bind(window), + clearTimeoutFn = window.clearTimeout.bind(window), + ackTimeoutMs = DEFAULT_ACK_TIMEOUT_MS, + retryDelayMs = DEFAULT_RETRY_DELAY_MS, +} = {}) => (store) => { + let roomsConnected = false; + let joinedRoomId = null; + let pendingJoinRequest = null; + let joinGeneration = 0; + let submissionGeneration = 0; + let submissionAckTimer = null; + let submissionRetryTimer = null; + let hydratedPendingResult = false; + let storageWarningSubmissionId = null; + + const currentSubmission = () => store.getState().room.resultSubmission; + const readStoredPendingResult = () => { + try { + return readPendingResult(storage); + } catch (storageError) { + return null; + } + }; + + const warnPendingResultNotBackedUp = (submissionId) => { + if (storageWarningSubmissionId === submissionId) { + return; + } + + storageWarningSubmissionId = submissionId; + store.dispatch(createMessage({ + severity: 'warning', + text: 'This result could not be backed up on this device. Keep this tab open while it submits.', + })); + }; + + const clearSubmissionTimers = () => { + if (submissionAckTimer !== null) { + clearTimeoutFn(submissionAckTimer); + submissionAckTimer = null; + } + if (submissionRetryTimer !== null) { + clearTimeoutFn(submissionRetryTimer); + submissionRetryTimer = null; + } + }; + + const invalidateSubmissionAttempt = () => { + submissionGeneration += 1; + clearSubmissionTimers(); + }; + + const normalizeSubmissionError = (error, fallbackMessage) => ({ + statusCode: error && error.statusCode, + message: (error && error.message) || fallbackMessage, + retryable: !!(error && error.retryable), + }); + + const normalizeResultForComparison = (result = {}) => ({ + time: result.time, + AUF: !!(result.penalties && result.penalties.AUF), + DNF: !!(result.penalties && result.penalties.DNF), + inspection: !!(result.penalties && result.penalties.inspection), + }); + + const resultEchoMatches = (echo, pendingResult) => { + const { room, user } = store.getState(); + const echoedSubmissionId = echo.result && echo.result.submissionId; + const pendingValue = normalizeResultForComparison(pendingResult.result); + const echoedValue = normalizeResultForComparison(echo.result); + + return pendingResultMatches(pendingResult, { userId: user.id, roomId: room._id }) + && String(joinedRoomId) === String(room._id) + && String(echo.userId) === String(user.id) + && String(echo.id) === String(pendingResult.attemptId) + && echoedSubmissionId === undefined + && pendingValue.time === echoedValue.time + && pendingValue.AUF === echoedValue.AUF + && pendingValue.DNF === echoedValue.DNF + && pendingValue.inspection === echoedValue.inspection; + }; + + let flushPendingResult; + + const completePendingResult = (pendingResult) => { + const submission = currentSubmission(); + if (!submission.pendingResult + || submission.pendingResult.submissionId !== pendingResult.submissionId) { + return false; + } + + invalidateSubmissionAttempt(); + try { + const cleared = clearPendingResult(pendingResult.submissionId, storage); + if (!cleared) { + const storedPendingResult = readStoredPendingResult(); + if (storedPendingResult) { + store.dispatch(resultSubmissionPending(storedPendingResult)); + flushPendingResult(); + return false; + } + } + } catch (storageError) { + store.dispatch(createMessage({ + severity: 'warning', + text: 'Your result was saved, but its local backup could not be removed.', + })); + } + store.dispatch(resultSubmissionCleared(pendingResult.submissionId)); + return true; + }; + + let namespace; + + flushPendingResult = () => { + const submission = currentSubmission(); + let pendingResult = submission && submission.pendingResult; + const { room, user } = store.getState(); + + if (!pendingResult + || submission.status === 'sending' + || submission.status === 'failed' + || !roomsConnected + || String(joinedRoomId) !== String(room._id) + || !pendingResultMatches(pendingResult, { userId: user.id, roomId: room._id })) { + return; + } + + if (submissionRetryTimer !== null) { + clearTimeoutFn(submissionRetryTimer); + submissionRetryTimer = null; + } + + if (pendingResult.deliveryAttempted !== true) { + pendingResult = markPendingResultAttempted(pendingResult); + store.dispatch(resultSubmissionPending(pendingResult)); + try { + persistPendingResult(pendingResult, storage); + } catch (storageError) { + warnPendingResultNotBackedUp(pendingResult.submissionId); + } + } + + const generation = submissionGeneration + 1; + submissionGeneration = generation; + store.dispatch(resultSubmissionSending(pendingResult.submissionId)); + + const retrySubmission = () => { + if (generation !== submissionGeneration) { + return; + } + + invalidateSubmissionAttempt(); + store.dispatch(resultSubmissionPending(pendingResult)); + submissionRetryTimer = setTimeoutFn(() => { + submissionRetryTimer = null; + flushPendingResult(); + }, retryDelayMs); + }; + + submissionAckTimer = setTimeoutFn(() => { + submissionAckTimer = null; + retrySubmission(); + }, ackTimeoutMs); + + namespace.emit(Protocol.SUBMIT_RESULT, { + id: pendingResult.attemptId, + attemptKey: pendingResult.attemptKey, + result: pendingResult.result, + submissionId: pendingResult.submissionId, + }, (error, receipt) => { + if (generation !== submissionGeneration) { + return; + } + + if (submissionAckTimer !== null) { + clearTimeoutFn(submissionAckTimer); + submissionAckTimer = null; + } + + if (error) { + if (error.retryable) { + retrySubmission(); + return; + } + + invalidateSubmissionAttempt(); + const submissionError = normalizeSubmissionError( + error, + 'The server rejected this result.', + ); + pendingResult = markPendingResultFailed(pendingResult, submissionError); + try { + persistPendingResult(pendingResult, storage); + } catch (storageError) { + warnPendingResultNotBackedUp(pendingResult.submissionId); + } + store.dispatch(resultSubmissionPending(pendingResult)); + store.dispatch(resultSubmissionFailed(pendingResult.submissionId, submissionError)); + return; + } + + const accepted = receipt + && receipt.submissionId === pendingResult.submissionId + && (receipt.status === 'saved' || receipt.status === 'duplicate'); + + if (!accepted) { + retrySubmission(); + return; + } + + completePendingResult(pendingResult); + }); + }; + const reconnectToRoom = () => { - if (store.getState().room.accessCode) { + const { room } = store.getState(); + const request = pendingJoinRequest || (room.accessCode ? { + id: room._id, + password: room.password, + } : null); + + if (request) { store.dispatch(joinRoom({ - id: store.getState().room._id, - password: store.getState().room.password, + ...request, + reconnecting: true, })); } }; - const namespace = new Namespace({ - manager, + const handleDisconnected = () => { + roomsConnected = false; + joinedRoomId = null; + joinGeneration += 1; + + const submission = currentSubmission(); + const pendingResult = submission && submission.pendingResult; + invalidateSubmissionAttempt(); + if (pendingResult && submission.status === 'sending') { + store.dispatch(resultSubmissionPending(pendingResult)); + } + + store.dispatch(disconnected()); + }; + + namespace = new NamespaceClass({ + manager: namespaceManager, namespace: '/rooms', onChange: (isConnected) => { store.dispatch(connectionChanged(isConnected)); }, onConnected: () => { + roomsConnected = true; + joinedRoomId = null; store.dispatch(connected()); reconnectToRoom(); }, - onDisconnected: () => { - store.dispatch(disconnected()); - }, + onDisconnected: handleDisconnected, events: { [Protocol.ERROR]: (error) => { // eslint-disable-next-line no-console @@ -171,11 +429,15 @@ const roomsNamespaceMiddleware = (store) => { } }, [Protocol.NEW_RESULT]: (result) => { + const submission = currentSubmission(); + if (submission.pendingResult + && resultEchoMatches(result, submission.pendingResult)) { + completePendingResult(submission.pendingResult); + } store.dispatch(newResult(result)); }, [Protocol.EDIT_RESULT]: (result) => { store.dispatch(editResult(result)); - // calculate grand prix points }, [Protocol.MESSAGE]: (message) => { store.dispatch(receiveChat(message)); @@ -203,7 +465,6 @@ const roomsNamespaceMiddleware = (store) => { }, }); - // catch attempt to join room here and then fetch socket event const reducers = { [ROOMS_CONNECT]: () => { namespace.connect(); @@ -211,17 +472,14 @@ const roomsNamespaceMiddleware = (store) => { [ROOMS_DISCONNECT]: () => { namespace.disconnect(); }, - // no real point in this being here oper other places '@@router/LOCATION_CHANGE': ({ payload }) => { - // TODO: improve if (payload.location.pathname === '/' || payload.location.pathname === '/profile') { document.title = 'Let\'s Cube'; } }, [USER_CHANGED]: () => { - // TODO: improve - manager.disconnect(); - manager.connect(); + namespaceManager.disconnect(); + namespaceManager.connect(); }, [DELETE_ROOM]: ({ id }) => { namespace.emit(Protocol.DELETE_ROOM, id, (err) => { @@ -234,7 +492,17 @@ const roomsNamespaceMiddleware = (store) => { }); }, [JOIN_ROOM]: ({ id, password }) => { + const generation = joinGeneration + 1; + joinGeneration = generation; + joinedRoomId = null; + pendingJoinRequest = { id, password }; namespace.emit(Protocol.JOIN_ROOM, { id, password }, (err, room) => { + if (generation !== joinGeneration) { + return; + } + + pendingJoinRequest = null; + if (err) { if (err.banned) { store.dispatch(push('/')); @@ -244,10 +512,13 @@ const roomsNamespaceMiddleware = (store) => { severity: 'error', text: err.message, })); + return; } if (room) { store.dispatch(roomUpdated(room)); + joinedRoomId = room._id || id; + flushPendingResult(); if (room.accessCode) { store.dispatch(createMessage({ @@ -268,18 +539,93 @@ const roomsNamespaceMiddleware = (store) => { return; } - // store.dispatch(roomJoined(room.accessCode)); store.dispatch(roomUpdated(room)); + joinedRoomId = room._id; store.dispatch(push(`/rooms/${room._id}`)); + flushPendingResult(); }); }, [LEAVE_ROOM]: () => { + joinedRoomId = null; + pendingJoinRequest = null; + joinGeneration += 1; if (store.getState().room._id) { namespace.emit(Protocol.LEAVE_ROOM); } }, [SUBMIT_RESULT]: (event) => { - namespace.emit(Protocol.SUBMIT_RESULT, event.result); + const submission = currentSubmission(); + const storedPendingResult = readStoredPendingResult(); + + if ((submission && submission.pendingResult) || storedPendingResult) { + if (!submission.pendingResult && storedPendingResult) { + store.dispatch(resultSubmissionPending(storedPendingResult)); + } + store.dispatch(createMessage({ + severity: 'warning', + text: 'A result is already waiting to be submitted.', + })); + return; + } + + const { room, user } = store.getState(); + let pendingResult; + try { + pendingResult = createPendingResult({ + userId: user.id, + roomId: room._id, + attemptId: event.result.id, + attemptKey: event.result.attemptKey, + result: event.result.result, + }, { + createId: createSubmissionId, + now, + }); + } catch (error) { + store.dispatch(createMessage({ + severity: 'warning', + text: 'This result is invalid and was not submitted.', + })); + return; + } + + store.dispatch(resultSubmissionPending(pendingResult)); + try { + persistPendingResult(pendingResult, storage); + } catch (storageError) { + warnPendingResultNotBackedUp(pendingResult.submissionId); + } + + flushPendingResult(); + }, + [DISCARD_PENDING_RESULT]: ({ submissionId }) => { + const submission = currentSubmission(); + if (!submission.pendingResult + || submission.pendingResult.submissionId !== submissionId) { + return; + } + + if (!canDiscardPendingResult(submission.pendingResult, submission.status)) { + store.dispatch(createMessage({ + severity: 'warning', + text: 'This result may already have reached the server and must finish submitting.', + })); + return; + } + + invalidateSubmissionAttempt(); + try { + if (!clearPendingResult(submissionId, storage)) { + throw new Error('A newer result is saved on this device.'); + } + } catch (storageError) { + store.dispatch(resultSubmissionFailed(submissionId, { + message: storageError.message || 'The saved result could not be discarded.', + retryable: false, + })); + return; + } + store.dispatch(resultSubmissionCleared(submissionId)); }, [SEND_EDIT_RESULT]: (event) => { namespace.emit(Protocol.SEND_EDIT_RESULT, event.result); @@ -334,13 +680,20 @@ const roomsNamespaceMiddleware = (store) => { }, }; - // Return the handler that will be called for each action dispatched return (next) => (action) => { + if (!hydratedPendingResult) { + hydratedPendingResult = true; + const pendingResult = readStoredPendingResult(); + if (pendingResult) { + store.dispatch(resultSubmissionPending(pendingResult)); + } + } + if (reducers[action.type]) { reducers[action.type](action); } - next(action); // This is a middleware, we still need to call this! + next(action); }; }; -export default roomsNamespaceMiddleware; +export default createRoomsNamespaceMiddleware(); diff --git a/client/src/store/middlewares/rooms.test.js b/client/src/store/middlewares/rooms.test.js new file mode 100644 index 0000000..57a03af --- /dev/null +++ b/client/src/store/middlewares/rooms.test.js @@ -0,0 +1,444 @@ +import { + applyMiddleware, combineReducers, createStore, +} from 'redux'; +import Protocol from '../../lib/protocol'; +import roomReducer from '../room/reducer'; +import roomsReducer from '../rooms/reducer'; +import messagesReducer from '../messages/reducers'; +import { + discardPendingResult, + joinRoom, + submitResult, +} from '../room/actions'; +import { connectSocket } from '../rooms/actions'; +import { + PENDING_RESULT_STORAGE_KEY, + createPendingResult, + markPendingResultAttempted, + markPendingResultFailed, + persistPendingResult, +} from '../room/resultOutbox'; +import { createRoomsNamespaceMiddleware } from './rooms'; + +jest.mock('./manager', () => ({ + manager: { + connect: jest.fn(), + disconnect: jest.fn(), + }, +})); + +class FakeNamespace { + constructor(options) { + this.options = options; + this.emitted = []; + FakeNamespace.instance = this; + } + + connect() { + this.connected = true; + } + + disconnect() { + this.connected = false; + } + + emit(event, ...args) { + this.emitted.push({ + event, + args, + pendingResultAtEmit: window.localStorage.getItem(PENDING_RESULT_STORAGE_KEY), + }); + } +} + +const makeScheduler = () => { + let nextId = 1; + const timers = new Map(); + + return { + setTimeoutFn: (callback, delay) => { + const id = nextId; + nextId += 1; + timers.set(id, { callback, delay }); + return id; + }, + clearTimeoutFn: (id) => timers.delete(id), + runNext: (delay) => { + const entry = Array.from(timers.entries()).find(([, timer]) => timer.delay === delay); + if (!entry) { + throw new Error(`No timer scheduled for ${delay}ms.`); + } + const [id, timer] = entry; + timers.delete(id); + timer.callback(); + }, + }; +}; + +const initialRoom = () => ({ + ...roomReducer(undefined, { type: '@@INIT' }), + fetching: false, + _id: 'room-one', + accessCode: 'ABC123', + password: null, + users: [{ id: 42, displayName: 'Cuber' }], + competing: { 42: true }, + waitingFor: { 42: true }, + attempts: [{ + _id: 'attempt-one', id: 12, scrambles: ['R U'], results: {}, + }], +}); + +const buildStore = ({ + roomState = initialRoom(), + storage = window.localStorage, +} = {}) => { + const scheduler = makeScheduler(); + const namespaceManager = { + connect: jest.fn(), + disconnect: jest.fn(), + }; + const middleware = createRoomsNamespaceMiddleware({ + NamespaceClass: FakeNamespace, + namespaceManager, + storage, + createSubmissionId: () => 'submission-one', + now: () => 1000, + setTimeoutFn: scheduler.setTimeoutFn, + clearTimeoutFn: scheduler.clearTimeoutFn, + ackTimeoutMs: 100, + retryDelayMs: 25, + }); + const reducer = combineReducers({ + room: roomReducer, + roomList: roomsReducer, + messages: messagesReducer, + user: (state = { id: 42, muteTimer: true }) => state, + chat: (state = {}) => state, + admin: (state = {}) => state, + }); + const store = createStore(reducer, { + room: roomState, + roomList: roomsReducer(undefined, { type: '@@INIT' }), + messages: messagesReducer(undefined, { type: '@@INIT' }), + user: { id: 42, muteTimer: true }, + chat: {}, + admin: {}, + }, applyMiddleware(middleware)); + + return { + namespace: FakeNamespace.instance, + scheduler, + store, + }; +}; + +const emissionsFor = (namespace, event) => ( + namespace.emitted.filter((emission) => emission.event === event) +); + +const acknowledgeJoin = (namespace) => { + const join = emissionsFor(namespace, Protocol.JOIN_ROOM).slice(-1)[0]; + const room = initialRoom(); + delete room.resultSubmission; + join.args[1](null, room); +}; + +const result = { + id: 12, + attemptKey: 'attempt-one', + result: { + time: 1234, + penalties: {}, + }, +}; + +describe('rooms result outbox middleware', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it('keeps the loaded room mounted while reconnecting its socket', () => { + const { namespace, store } = buildStore(); + const { attempts } = store.getState().room; + store.dispatch(connectSocket()); + + namespace.options.onConnected(); + + expect(emissionsFor(namespace, Protocol.JOIN_ROOM)).toHaveLength(1); + expect(store.getState().room.fetching).toBe(false); + expect(store.getState().room.attempts).toBe(attempts); + }); + + it('persists while disconnected and submits only after the room join acknowledgement', () => { + const { namespace, store } = buildStore(); + store.dispatch(connectSocket()); + + store.dispatch(submitResult(result)); + + expect(window.localStorage.getItem(PENDING_RESULT_STORAGE_KEY)).not.toBeNull(); + expect(emissionsFor(namespace, Protocol.SUBMIT_RESULT)).toHaveLength(0); + + namespace.options.onConnected(); + expect(emissionsFor(namespace, Protocol.JOIN_ROOM)).toHaveLength(1); + expect(emissionsFor(namespace, Protocol.SUBMIT_RESULT)).toHaveLength(0); + + acknowledgeJoin(namespace); + + const submission = emissionsFor(namespace, Protocol.SUBMIT_RESULT)[0]; + expect(submission.pendingResultAtEmit).not.toBeNull(); + expect(JSON.parse(submission.pendingResultAtEmit).deliveryAttempted).toBe(true); + expect(submission.args[0]).toEqual({ + id: 12, + attemptKey: 'attempt-one', + result: result.result, + submissionId: 'submission-one', + }); + + submission.args[1](null, { + submissionId: 'submission-one', + status: 'saved', + }); + + expect(window.localStorage.getItem(PENDING_RESULT_STORAGE_KEY)).toBeNull(); + expect(store.getState().room.resultSubmission.status).toBe('idle'); + }); + + it('still submits from memory when local storage is unavailable', () => { + const storage = { + getItem: jest.fn(() => null), + setItem: jest.fn(() => { + throw new Error('storage unavailable'); + }), + removeItem: jest.fn(), + }; + const { namespace, store } = buildStore({ storage }); + store.dispatch(connectSocket()); + namespace.options.onConnected(); + acknowledgeJoin(namespace); + + store.dispatch(submitResult(result)); + + expect(storage.setItem).toHaveBeenCalledTimes(2); + expect(emissionsFor(namespace, Protocol.SUBMIT_RESULT)).toHaveLength(1); + expect(store.getState().room.resultSubmission.status).toBe('sending'); + expect(store.getState().messages.messages.some((message) => ( + message.text.includes('Keep this tab open') + ))).toBe(true); + }); + + it('retries a transient error with the same submission id', () => { + const { namespace, scheduler, store } = buildStore(); + store.dispatch(connectSocket()); + namespace.options.onConnected(); + acknowledgeJoin(namespace); + + store.dispatch(submitResult(result)); + const firstSubmission = emissionsFor(namespace, Protocol.SUBMIT_RESULT)[0]; + firstSubmission.args[1]({ message: 'MongoDB unavailable', retryable: true }); + + expect(store.getState().room.resultSubmission.status).toBe('pending'); + scheduler.runNext(25); + + const submissions = emissionsFor(namespace, Protocol.SUBMIT_RESULT); + expect(submissions).toHaveLength(2); + expect(submissions[1].args[0].submissionId).toBe(submissions[0].args[0].submissionId); + }); + + it('does not discard a result after delivery has started', () => { + const { namespace, scheduler, store } = buildStore(); + store.dispatch(connectSocket()); + namespace.options.onConnected(); + acknowledgeJoin(namespace); + + store.dispatch(submitResult(result)); + scheduler.runNext(100); + store.dispatch(discardPendingResult('submission-one')); + + expect(store.getState().room.resultSubmission.status).toBe('pending'); + expect(window.localStorage.getItem(PENDING_RESULT_STORAGE_KEY)).not.toBeNull(); + + scheduler.runNext(25); + expect(emissionsFor(namespace, Protocol.SUBMIT_RESULT)).toHaveLength(2); + }); + + it('protects a restored result that may already have reached the server', () => { + const restoredResult = markPendingResultAttempted(createPendingResult({ + userId: 42, + roomId: 'room-one', + attemptId: 12, + attemptKey: 'attempt-one', + result: { time: 1234, penalties: {} }, + }, { + createId: () => 'restored-submission', + now: () => 500, + })); + persistPendingResult(restoredResult); + + const { store } = buildStore(); + store.dispatch(discardPendingResult('restored-submission')); + + expect(store.getState().room.resultSubmission.pendingResult).toEqual(restoredResult); + expect(window.localStorage.getItem(PENDING_RESULT_STORAGE_KEY)).not.toBeNull(); + }); + + it('rejects an invalid result without blocking the next valid result', () => { + const { namespace, store } = buildStore(); + store.dispatch(connectSocket()); + namespace.options.onConnected(); + acknowledgeJoin(namespace); + + store.dispatch(submitResult({ + ...result, + result: { time: undefined, penalties: {} }, + })); + + expect(store.getState().room.resultSubmission.status).toBe('idle'); + expect(emissionsFor(namespace, Protocol.SUBMIT_RESULT)).toHaveLength(0); + + store.dispatch(submitResult(result)); + expect(emissionsFor(namespace, Protocol.SUBMIT_RESULT)).toHaveLength(1); + }); + + it('accepts an exact self result echo as a legacy persistence receipt', () => { + const { namespace, store } = buildStore(); + store.dispatch(connectSocket()); + namespace.options.onConnected(); + acknowledgeJoin(namespace); + + store.dispatch(submitResult(result)); + const submission = emissionsFor(namespace, Protocol.SUBMIT_RESULT)[0]; + + namespace.options.events[Protocol.NEW_RESULT]({ + id: 12, + userId: 42, + result: { time: 1234, penalties: { DNF: true } }, + }); + expect(store.getState().room.resultSubmission.status).toBe('sending'); + + namespace.options.events[Protocol.NEW_RESULT]({ + id: 12, + userId: 42, + result: { time: 1234, penalties: {}, submissionId: 'another-submission' }, + }); + expect(store.getState().room.resultSubmission.status).toBe('sending'); + + namespace.options.events[Protocol.NEW_RESULT]({ + id: 12, + userId: 42, + result: { time: 1234, penalties: {}, submissionId: 'submission-one' }, + }); + expect(store.getState().room.resultSubmission.status).toBe('sending'); + + namespace.options.events[Protocol.NEW_RESULT]({ + id: 12, + userId: 42, + result: { time: 1234, penalties: {} }, + }); + + expect(window.localStorage.getItem(PENDING_RESULT_STORAGE_KEY)).toBeNull(); + expect(store.getState().room.resultSubmission.status).toBe('idle'); + + submission.args[1](null, { + submissionId: 'submission-one', + status: 'saved', + }); + expect(store.getState().room.resultSubmission.status).toBe('idle'); + }); + + it('retains a terminal failure until the user explicitly discards it', () => { + const { namespace, store } = buildStore(); + store.dispatch(connectSocket()); + namespace.options.onConnected(); + acknowledgeJoin(namespace); + + store.dispatch(submitResult(result)); + const submission = emissionsFor(namespace, Protocol.SUBMIT_RESULT)[0]; + submission.args[1]({ message: 'Attempt already has a result', retryable: false }); + + expect(store.getState().room.resultSubmission.status).toBe('failed'); + expect(JSON.parse( + window.localStorage.getItem(PENDING_RESULT_STORAGE_KEY), + ).terminalFailure).toBe(true); + + store.dispatch(discardPendingResult('submission-one')); + + expect(store.getState().room.resultSubmission.status).toBe('idle'); + expect(window.localStorage.getItem(PENDING_RESULT_STORAGE_KEY)).toBeNull(); + }); + + it('restores a terminal failure as discardable after a refresh', () => { + const failedResult = markPendingResultFailed(createPendingResult({ + userId: 42, + roomId: 'room-one', + attemptId: 12, + attemptKey: 'attempt-one', + result: { time: 1234, penalties: {} }, + }, { + createId: () => 'restored-submission', + now: () => 500, + }), { + statusCode: 409, + message: 'A result already exists for this attempt', + retryable: false, + }); + persistPendingResult(failedResult); + + const { store } = buildStore(); + store.dispatch(discardPendingResult('restored-submission')); + + expect(store.getState().room.resultSubmission.status).toBe('idle'); + expect(window.localStorage.getItem(PENDING_RESULT_STORAGE_KEY)).toBeNull(); + }); + + it('does not submit a restored result for a different user or room', () => { + const restoredResult = createPendingResult({ + userId: 99, + roomId: 'room-two', + attemptId: 8, + attemptKey: 'attempt-two', + result: { time: 999, penalties: {} }, + }, { + createId: () => 'restored-submission', + now: () => 500, + }); + persistPendingResult(restoredResult); + + const { namespace, store } = buildStore(); + store.dispatch(connectSocket()); + namespace.options.onConnected(); + acknowledgeJoin(namespace); + + expect(store.getState().room.resultSubmission.pendingResult).toEqual(restoredResult); + expect(emissionsFor(namespace, Protocol.SUBMIT_RESULT)).toHaveLength(0); + }); + + it('replays an interrupted initial room join after reconnecting', () => { + const { namespace, store } = buildStore({ + roomState: { + ...initialRoom(), + _id: null, + accessCode: null, + }, + }); + store.dispatch(connectSocket()); + namespace.options.onConnected(); + store.dispatch(joinRoom({ id: 'room-two', password: 'secret' })); + + expect(emissionsFor(namespace, Protocol.JOIN_ROOM)).toHaveLength(1); + namespace.options.onDisconnected(); + namespace.options.onConnected(); + + const joins = emissionsFor(namespace, Protocol.JOIN_ROOM); + expect(joins).toHaveLength(2); + expect(joins[1].args[0]).toEqual({ id: 'room-two', password: 'secret' }); + + joins[1].args[1](null, { + ...initialRoom(), + _id: 'room-two', + accessCode: 'ROOM2', + }); + expect(store.getState().room.fetching).toBe(false); + expect(store.getState().room._id).toBe('room-two'); + }); +}); diff --git a/client/src/store/room/actions.js b/client/src/store/room/actions.js index 811b8cd..e954a92 100644 --- a/client/src/store/room/actions.js +++ b/client/src/store/room/actions.js @@ -27,6 +27,11 @@ export const NEXT_SOLVE_AT = 'room/next_solve_at'; export const START_ROOM = 'room/start_room'; export const PAUSE_ROOM = 'room/pause_room'; export const TOGGLE_FOLLOW_USER = 'room/toggle_follow_user'; +export const RESULT_SUBMISSION_PENDING = 'room/result_submission_pending'; +export const RESULT_SUBMISSION_SENDING = 'room/result_submission_sending'; +export const RESULT_SUBMISSION_FAILED = 'room/result_submission_failed'; +export const RESULT_SUBMISSION_CLEARED = 'room/result_submission_cleared'; +export const DISCARD_PENDING_RESULT = 'room/discard_pending_result'; export const roomUpdated = (room) => ({ type: ROOM_UPDATED, @@ -43,11 +48,14 @@ export const deleteRoom = (id) => ({ id, }); -export const joinRoom = ({ id, spectating, password }) => ({ +export const joinRoom = ({ + id, spectating, password, reconnecting = false, +}) => ({ type: JOIN_ROOM, id, spectating, password, + reconnecting, }); // We're submitting a new result @@ -56,6 +64,32 @@ export const submitResult = (result) => ({ result, }); +export const resultSubmissionPending = (pendingResult) => ({ + type: RESULT_SUBMISSION_PENDING, + pendingResult, +}); + +export const resultSubmissionSending = (submissionId) => ({ + type: RESULT_SUBMISSION_SENDING, + submissionId, +}); + +export const resultSubmissionFailed = (submissionId, error) => ({ + type: RESULT_SUBMISSION_FAILED, + submissionId, + error, +}); + +export const resultSubmissionCleared = (submissionId) => ({ + type: RESULT_SUBMISSION_CLEARED, + submissionId, +}); + +export const discardPendingResult = (submissionId) => ({ + type: DISCARD_PENDING_RESULT, + submissionId, +}); + // A new result came in export const newResult = (result) => ({ type: NEW_RESULT, diff --git a/client/src/store/room/reducer.js b/client/src/store/room/reducer.js index f371728..0a7fd65 100644 --- a/client/src/store/room/reducer.js +++ b/client/src/store/room/reducer.js @@ -16,8 +16,18 @@ import { UPDATE_USER_BANNED, NEXT_SOLVE_AT, TOGGLE_FOLLOW_USER, + RESULT_SUBMISSION_PENDING, + RESULT_SUBMISSION_SENDING, + RESULT_SUBMISSION_FAILED, + RESULT_SUBMISSION_CLEARED, } from './actions'; +const EMPTY_RESULT_SUBMISSION = { + status: 'idle', + pendingResult: null, + error: null, +}; + const INITIAL_STATE = { fetching: null, _id: null, @@ -47,21 +57,30 @@ const INITIAL_STATE = { following: {}, registeredUsers: 0, twitchChannel: '', + resultSubmission: EMPTY_RESULT_SUBMISSION, }; -const editResult = (state, action) => ({ - ...state, - attempts: state.attempts.map((attempt) => ( - attempt.id === action.result.id ? ({ - ...attempt, - results: { ...attempt.results, [action.result.userId]: action.result.result }, - }) : attempt - )), - waitingFor: { - ...state.waitingFor, - [action.result.userId]: false, - }, -}); +const editResult = (state, action) => { + const latestAttempt = state.attempts[state.attempts.length - 1]; + const resultIsForLatestAttempt = latestAttempt + && latestAttempt.id === action.result.id; + + return { + ...state, + attempts: state.attempts.map((attempt) => ( + attempt.id === action.result.id ? ({ + ...attempt, + results: { ...attempt.results, [action.result.userId]: action.result.result }, + }) : attempt + )), + waitingFor: resultIsForLatestAttempt + ? { + ...state.waitingFor, + [action.result.userId]: false, + } + : state.waitingFor, + }; +}; const reducers = { [ROOM_UPDATED]: (state, { room }) => ({ @@ -94,14 +113,16 @@ const reducers = { }), [JOIN_ROOM]: (state, action) => ({ ...state, - fetching: true, + fetching: action.reconnecting ? state.fetching : true, password: action.password, }), - [RESET_ROOM]: () => ({ + [RESET_ROOM]: (state) => ({ ...INITIAL_STATE, + resultSubmission: state.resultSubmission, }), - [LEAVE_ROOM]: () => ({ + [LEAVE_ROOM]: (state) => ({ ...INITIAL_STATE, + resultSubmission: state.resultSubmission, }), [NEW_ATTEMPT]: (state, action) => ({ ...state, @@ -151,6 +172,49 @@ const reducers = { [action.userId]: !state.following[action.userId], }, }), + [RESULT_SUBMISSION_PENDING]: (state, action) => ({ + ...state, + resultSubmission: { + status: action.pendingResult.terminalFailure ? 'failed' : 'pending', + pendingResult: action.pendingResult, + error: action.pendingResult.terminalFailure ? action.pendingResult.failure : null, + }, + }), + [RESULT_SUBMISSION_SENDING]: (state, action) => ( + state.resultSubmission.pendingResult + && state.resultSubmission.pendingResult.submissionId === action.submissionId + ? { + ...state, + resultSubmission: { + ...state.resultSubmission, + status: 'sending', + error: null, + }, + } + : state + ), + [RESULT_SUBMISSION_FAILED]: (state, action) => ( + state.resultSubmission.pendingResult + && state.resultSubmission.pendingResult.submissionId === action.submissionId + ? { + ...state, + resultSubmission: { + ...state.resultSubmission, + status: 'failed', + error: action.error, + }, + } + : state + ), + [RESULT_SUBMISSION_CLEARED]: (state, action) => ( + state.resultSubmission.pendingResult + && state.resultSubmission.pendingResult.submissionId === action.submissionId + ? { + ...state, + resultSubmission: EMPTY_RESULT_SUBMISSION, + } + : state + ), }; // Socket reducer diff --git a/client/src/store/room/reducer.test.js b/client/src/store/room/reducer.test.js new file mode 100644 index 0000000..f8181f5 --- /dev/null +++ b/client/src/store/room/reducer.test.js @@ -0,0 +1,66 @@ +import { joinRoom, newResult } from './actions'; +import roomReducer from './reducer'; + +const initialState = () => ({ + ...roomReducer(undefined, { type: '@@INIT' }), + attempts: [{ + _id: 'old-attempt', + id: 0, + results: {}, + }, { + _id: 'latest-attempt', + id: 1, + results: {}, + }], + waitingFor: { 42: true }, +}); + +describe('room result updates', () => { + it('does not clear current waiting state for a delayed historical result', () => { + const state = roomReducer(initialState(), newResult({ + id: 0, + userId: 42, + result: { time: 1234, penalties: {} }, + })); + + expect(state.attempts[0].results[42]).toEqual({ time: 1234, penalties: {} }); + expect(state.waitingFor[42]).toBe(true); + }); + + it('clears waiting state when the result belongs to the latest attempt', () => { + const state = roomReducer(initialState(), newResult({ + id: 1, + userId: 42, + result: { time: 2345, penalties: {} }, + })); + + expect(state.attempts[1].results[42]).toEqual({ time: 2345, penalties: {} }); + expect(state.waitingFor[42]).toBe(false); + }); +}); + +describe('room joins', () => { + it('shows the loading state for an initial room join', () => { + const state = roomReducer(undefined, joinRoom({ id: 'room-1' })); + + expect(state.fetching).toBe(true); + }); + + it('keeps an active room mounted during a reconnect join', () => { + const loadedRoom = { + ...roomReducer(undefined, { type: '@@INIT' }), + fetching: false, + _id: 'room-1', + accessCode: 'access-code', + attempts: [{ _id: 'attempt-1', id: 0 }], + }; + + const state = roomReducer(loadedRoom, joinRoom({ + id: 'room-1', + reconnecting: true, + })); + + expect(state.fetching).toBe(false); + expect(state.attempts).toBe(loadedRoom.attempts); + }); +}); diff --git a/client/src/store/room/resultOutbox.js b/client/src/store/room/resultOutbox.js new file mode 100644 index 0000000..265adb5 --- /dev/null +++ b/client/src/store/room/resultOutbox.js @@ -0,0 +1,152 @@ +import { uuid } from '../../lib/utils'; + +export const PENDING_RESULT_VERSION = 1; +export const PENDING_RESULT_STORAGE_KEY = 'letscube.pendingResult.v1'; + +const hasId = (value) => ( + (typeof value === 'string' && value.length > 0) + || (typeof value === 'number' && Number.isFinite(value)) +); + +export const isPendingResult = (pendingResult) => ( + !!pendingResult + && pendingResult.version === PENDING_RESULT_VERSION + && typeof pendingResult.submissionId === 'string' + && pendingResult.submissionId.length > 0 + && hasId(pendingResult.userId) + && hasId(pendingResult.roomId) + && hasId(pendingResult.attemptId) + && (pendingResult.attemptKey === undefined || hasId(pendingResult.attemptKey)) + && (pendingResult.deliveryAttempted === undefined + || typeof pendingResult.deliveryAttempted === 'boolean') + && (pendingResult.terminalFailure === undefined + || typeof pendingResult.terminalFailure === 'boolean') + && (!pendingResult.terminalFailure + || (!!pendingResult.failure + && typeof pendingResult.failure === 'object' + && typeof pendingResult.failure.message === 'string' + && pendingResult.failure.message.length > 0)) + && !!pendingResult.result + && typeof pendingResult.result === 'object' + && typeof pendingResult.result.time === 'number' + && Number.isFinite(pendingResult.result.time) + && typeof pendingResult.createdAt === 'number' + && Number.isFinite(pendingResult.createdAt) +); + +export const createPendingResult = ({ + userId, + roomId, + attemptId, + attemptKey, + result, +}, { + createId = uuid, + now = Date.now, +} = {}) => { + if (!hasId(attemptKey)) { + throw new Error('A new pending result requires an immutable attempt key.'); + } + + const pendingResult = { + version: PENDING_RESULT_VERSION, + submissionId: createId(), + userId, + roomId, + attemptId, + attemptKey, + deliveryAttempted: false, + terminalFailure: false, + result, + createdAt: now(), + }; + + if (!isPendingResult(pendingResult)) { + throw new Error('Cannot create an invalid pending result.'); + } + + return pendingResult; +}; + +export const markPendingResultAttempted = (pendingResult) => { + const attemptedResult = { + ...pendingResult, + deliveryAttempted: true, + }; + + if (!isPendingResult(attemptedResult)) { + throw new Error('Cannot mark an invalid pending result as attempted.'); + } + + return attemptedResult; +}; + +export const markPendingResultFailed = (pendingResult, failure) => { + const failedResult = { + ...pendingResult, + deliveryAttempted: true, + terminalFailure: true, + failure, + }; + + if (!isPendingResult(failedResult)) { + throw new Error('Cannot mark an invalid pending result as failed.'); + } + + return failedResult; +}; + +export const canDiscardPendingResult = (pendingResult, status) => ( + isPendingResult(pendingResult) + && (status === 'failed' + || pendingResult.terminalFailure === true + || pendingResult.deliveryAttempted !== true) +); + +export const readPendingResult = (storage = window.localStorage) => { + const serialized = storage.getItem(PENDING_RESULT_STORAGE_KEY); + if (!serialized) { + return null; + } + + try { + const pendingResult = JSON.parse(serialized); + return isPendingResult(pendingResult) ? pendingResult : null; + } catch (error) { + return null; + } +}; + +export const persistPendingResult = (pendingResult, storage = window.localStorage) => { + if (!isPendingResult(pendingResult)) { + throw new Error('Cannot save an invalid pending result.'); + } + + storage.setItem(PENDING_RESULT_STORAGE_KEY, JSON.stringify(pendingResult)); + return pendingResult; +}; + +export const clearPendingResult = (submissionId, storage = window.localStorage) => { + const pendingResult = readPendingResult(storage); + + if (pendingResult && pendingResult.submissionId !== submissionId) { + return false; + } + + storage.removeItem(PENDING_RESULT_STORAGE_KEY); + return true; +}; + +export const pendingResultMatches = (pendingResult, { userId, roomId }) => ( + isPendingResult(pendingResult) + && hasId(userId) + && hasId(roomId) + && String(pendingResult.userId) === String(userId) + && String(pendingResult.roomId) === String(roomId) +); + +export const pendingResultBelongsToUser = (pendingResult, userId) => ( + isPendingResult(pendingResult) + && hasId(userId) + && String(pendingResult.userId) === String(userId) +); diff --git a/client/src/store/room/resultOutbox.test.js b/client/src/store/room/resultOutbox.test.js new file mode 100644 index 0000000..014a409 --- /dev/null +++ b/client/src/store/room/resultOutbox.test.js @@ -0,0 +1,121 @@ +import { + PENDING_RESULT_STORAGE_KEY, + canDiscardPendingResult, + clearPendingResult, + createPendingResult, + markPendingResultAttempted, + markPendingResultFailed, + pendingResultBelongsToUser, + pendingResultMatches, + persistPendingResult, + readPendingResult, +} from './resultOutbox'; + +const makePendingResult = (overrides = {}) => createPendingResult({ + userId: 42, + roomId: 'room-one', + attemptId: 12, + attemptKey: 'attempt-one', + result: { + time: 1234, + penalties: {}, + }, + ...overrides, +}, { + createId: () => 'submission-one', + now: () => 1000, +}); + +describe('result outbox storage', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it('persists and restores the complete pending result', () => { + const pendingResult = makePendingResult(); + + persistPendingResult(pendingResult); + + const serialized = window.localStorage.getItem(PENDING_RESULT_STORAGE_KEY); + expect(JSON.parse(serialized)).toEqual(pendingResult); + expect(readPendingResult()).toEqual(pendingResult); + }); + + it('only clears the matching submission', () => { + const pendingResult = makePendingResult(); + persistPendingResult(pendingResult); + + expect(clearPendingResult('another-submission')).toBe(false); + expect(readPendingResult()).toEqual(pendingResult); + + expect(clearPendingResult(pendingResult.submissionId)).toBe(true); + expect(readPendingResult()).toBeNull(); + }); + + it('matches both user and room before allowing automatic submission', () => { + const pendingResult = makePendingResult(); + + expect(pendingResultBelongsToUser(pendingResult, '42')).toBe(true); + expect(pendingResultMatches( + pendingResult, + { userId: '42', roomId: 'room-one' }, + )).toBe(true); + expect(pendingResultMatches(pendingResult, { userId: 7, roomId: 'room-one' })).toBe(false); + expect(pendingResultMatches(pendingResult, { userId: 42, roomId: 'room-two' })).toBe(false); + }); + + it('does not restore malformed storage data', () => { + window.localStorage.setItem(PENDING_RESULT_STORAGE_KEY, '{not-json'); + expect(readPendingResult()).toBeNull(); + + window.localStorage.setItem(PENDING_RESULT_STORAGE_KEY, JSON.stringify({ version: 1 })); + expect(readPendingResult()).toBeNull(); + }); + + it('restores legacy pending results without an attempt key', () => { + const pendingResult = makePendingResult(); + delete pendingResult.attemptKey; + window.localStorage.setItem( + PENDING_RESULT_STORAGE_KEY, + JSON.stringify(pendingResult), + ); + + expect(readPendingResult()).toEqual(pendingResult); + }); + + it('requires new pending results to identify the immutable attempt', () => { + expect(() => makePendingResult({ attemptKey: undefined })).toThrow( + 'requires an immutable attempt key', + ); + }); + + it.each([undefined, Number.NaN])('rejects an invalid result time', (time) => { + expect(() => makePendingResult({ + result: { time, penalties: {} }, + })).toThrow('Cannot create an invalid pending result'); + }); + + it('protects a result from discard after delivery starts', () => { + const pendingResult = makePendingResult(); + const attemptedResult = markPendingResultAttempted(pendingResult); + + expect(canDiscardPendingResult(pendingResult, 'pending')).toBe(true); + expect(attemptedResult.deliveryAttempted).toBe(true); + expect(canDiscardPendingResult(attemptedResult, 'sending')).toBe(false); + expect(canDiscardPendingResult(attemptedResult, 'pending')).toBe(false); + expect(canDiscardPendingResult(attemptedResult, 'failed')).toBe(true); + }); + + it('persists a terminal failure as safe to discard', () => { + const failedResult = markPendingResultFailed(makePendingResult(), { + statusCode: 409, + message: 'A result already exists for this attempt', + retryable: false, + }); + persistPendingResult(failedResult); + + const restoredResult = readPendingResult(); + expect(restoredResult).toEqual(failedResult); + expect(canDiscardPendingResult(restoredResult, 'pending')).toBe(true); + }); +}); 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..f75bc21 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 @@ -18,6 +21,9 @@ services: mongo: restart: unless-stopped + postgres: + restart: unless-stopped + redis: restart: unless-stopped diff --git a/compose.yml b/compose.yml index 72ceca4..e22418e 100644 --- a/compose.yml +++ b/compose.yml @@ -4,8 +4,21 @@ x-app-environment: &app-environment NODE_ENV: ${NODE_ENV:-prod} PORT: ${PORT:-8080} SOCKETIO_PORT: ${SOCKETIO_PORT:-9000} + ROOM_RECONNECT_GRACE_MS: ${ROOM_RECONNECT_GRACE_MS:-60000} + GRAND_PRIX_ENABLED: ${GRAND_PRIX_ENABLED:-false} 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 +28,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"] @@ -28,8 +51,9 @@ services: expose: - "8080" healthcheck: - test: ["CMD-SHELL", "node -e \"require('http').get({host:'127.0.0.1',port:process.env.PORT||8080,path:'/'},res=>process.exit(res.statusCode<500?0:1)).on('error',()=>process.exit(1))\""] + test: ["CMD-SHELL", "node -e \"require('http').get({host:'127.0.0.1',port:process.env.PORT||8080,path:'/health/api'},res=>process.exit(res.statusCode===200?0:1)).on('error',()=>process.exit(1))\""] interval: 30s + start_interval: 2s timeout: 5s retries: 5 start_period: 30s @@ -47,8 +71,9 @@ services: expose: - "9000" healthcheck: - test: ["CMD-SHELL", "node -e \"require('http').get({host:'127.0.0.1',port:process.env.SOCKETIO_PORT||9000,path:'/socket.io/?EIO=4&transport=polling'},res=>process.exit(res.statusCode<500?0:1)).on('error',()=>process.exit(1))\""] + test: ["CMD-SHELL", "node -e \"require('http').get({host:'127.0.0.1',port:process.env.SOCKETIO_PORT||9000,path:'/health/socket'},res=>process.exit(res.statusCode===200?0:1)).on('error',()=>process.exit(1))\""] interval: 30s + start_interval: 2s timeout: 5s retries: 5 start_period: 30s @@ -64,6 +89,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 +117,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/nginx.conf b/nginx.conf index ac675c1..88c98f2 100644 --- a/nginx.conf +++ b/nginx.conf @@ -29,6 +29,10 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + location = /health/socket { + proxy_pass http://socket:9000/health/socket; + } + location /socket.io/ { proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; 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/scripts/classify-deploy-target.sh b/scripts/classify-deploy-target.sh new file mode 100755 index 0000000..1afbb6c --- /dev/null +++ b/scripts/classify-deploy-target.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Classify changed paths for scripts/deploy.sh. Paths may be passed as +# arguments or, for ad-hoc use, one per line on stdin. +if [ "$#" -eq 0 ] && [ ! -t 0 ]; then + mapfile -t changed_paths +else + changed_paths=("$@") +fi + +target="none" + +for path in "${changed_paths[@]}"; do + case "$path" in + # Repository documentation and CI/agent metadata do not affect runtime. + .github/*|.agents/*|.codex/*|docs/*|AGENTS.md|README.md|README_*.md|LICENSE|LICENSE.*|*.md) + ;; + + # The server imports this client-owned protocol file at runtime. + client/src/lib/protocol.js|client/src/lib/protocol.json|client/src/lib/events.json) + echo "all" + exit 0 + ;; + + # The API image serves the built browser client. A client-only change does + # not require interrupting existing Socket.IO connections. + client/*) + target="api" + ;; + + # Server, dependency, container, deployment, and unknown changes are + # intentionally conservative because API and socket share one image. + *) + echo "all" + exit 0 + ;; + esac +done + +echo "$target" diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 4f292e4..b177e38 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -7,8 +7,35 @@ REMOTE="${REMOTE:-origin}" BRANCH="${BRANCH:-master}" DEPLOY_STRATEGY="${DEPLOY_STRATEGY:-ff-only}" DEPLOY_FORCE="${DEPLOY_FORCE:-false}" +DEPLOY_TARGET="${DEPLOY_TARGET:-auto}" +DEPLOY_WAIT_TIMEOUT_SECONDS="${DEPLOY_WAIT_TIMEOUT_SECONDS:-60}" COMPOSE=(docker compose -f compose.yml -f compose.prod.yml --env-file "$ENV_FILE") +ROLLBACK_COMPOSE=() +rollback_compose_dir="" + +cleanup_deploy_files() { + if [ -n "$rollback_compose_dir" ]; then + rm -rf -- "$rollback_compose_dir" + fi +} + +trap cleanup_deploy_files EXIT + +case "$DEPLOY_TARGET" in + auto|api|socket|all|none) + ;; + *) + echo "Unsupported DEPLOY_TARGET: $DEPLOY_TARGET" >&2 + echo "Use auto, api, socket, all, or none." >&2 + exit 2 + ;; +esac + +if ! [[ "$DEPLOY_WAIT_TIMEOUT_SECONDS" =~ ^[1-9][0-9]*$ ]]; then + echo "DEPLOY_WAIT_TIMEOUT_SECONDS must be a positive integer." >&2 + exit 2 +fi cd "$APP_DIR" @@ -30,16 +57,251 @@ case "$DEPLOY_STRATEGY" in esac current_commit="$(git rev-parse HEAD)" -if [ "$previous_commit" = "$current_commit" ] && [ "$DEPLOY_FORCE" != "true" ]; then - echo "Already up to date at $current_commit" - "${COMPOSE[@]}" ps - exit 0 + +if [ "$DEPLOY_TARGET" = "auto" ]; then + missing_services=() + for service in api socket nginx; do + if ! container_id="$("${COMPOSE[@]}" ps --status running -q "$service")"; then + echo "Failed to inspect the $service container." >&2 + exit 1 + fi + if [ -z "$container_id" ]; then + missing_services+=("$service") + fi + done + + if [ "${#missing_services[@]}" -gt 0 ]; then + echo "Missing runtime services: ${missing_services[*]}; deploying all." + resolved_target="all" + elif [ "$previous_commit" = "$current_commit" ]; then + if [ "$DEPLOY_FORCE" != "true" ]; then + echo "Already up to date at $current_commit" + "${COMPOSE[@]}" ps + exit 0 + fi + + # There is no diff to classify. DEPLOY_FORCE preserves the historical + # behavior of replacing the complete application deployment. + resolved_target="all" + else + mapfile -d '' changed_files < <( + git diff --name-only --no-renames -z "$previous_commit" "$current_commit" + ) + resolved_target="$( + bash scripts/classify-deploy-target.sh "${changed_files[@]}" + )" + fi +else + resolved_target="$DEPLOY_TARGET" fi -if ! "${COMPOSE[@]}" up -d --build; then - echo "Deployment failed. Recent service logs:" >&2 - "${COMPOSE[@]}" logs --tail=120 api socket nginx >&2 || true +case "$resolved_target" in + api) + selected_services=(api) + ;; + socket) + selected_services=(socket) + ;; + all) + # Roll out the backward-compatible socket handler before serving the new + # browser client that depends on its result acknowledgments. + selected_services=(socket api) + ;; + none) + echo "Updated checkout to $current_commit; no application services selected." + "${COMPOSE[@]}" ps + exit 0 + ;; + *) + echo "Deploy classifier returned an invalid target: $resolved_target" >&2 + exit 2 + ;; +esac + +echo "Deploying commit $current_commit (target: $resolved_target)" + +print_retry_hint() { + echo "Retry this checked-out commit with DEPLOY_TARGET=$resolved_target after correcting the failure." >&2 +} + +prepare_rollback_compose() { + local project_dir + + project_dir="$(pwd -P)" + rollback_compose_dir="$(mktemp -d "${TMPDIR:-/tmp}/letscube-deploy.XXXXXX")" + + if ! git show "$previous_commit:compose.yml" > "$rollback_compose_dir/compose.yml" \ + || ! git show "$previous_commit:compose.prod.yml" \ + > "$rollback_compose_dir/compose.prod.yml"; then + echo "Failed to snapshot pre-deploy Compose configuration." >&2 + return 1 + fi + + ROLLBACK_COMPOSE=( + docker compose + --project-directory "$project_dir" + -f "$rollback_compose_dir/compose.yml" + -f "$rollback_compose_dir/compose.prod.yml" + --env-file "$ENV_FILE" + ) +} + +if ! prepare_rollback_compose; then + print_retry_hint exit 1 fi +# Shell environment takes precedence over APP_IMAGE_TAG in the env file. This +# gives every deployed revision an immutable image tag while keeping direct +# Compose usage compatible with the default `local` tag. +export APP_IMAGE_TAG="$current_commit" + +if ! "${COMPOSE[@]}" build api; then + echo "Application image build failed; no application containers were replaced." >&2 + print_retry_hint + exit 1 +fi + +if ! "${COMPOSE[@]}" up -d --no-recreate --wait \ + --wait-timeout "$DEPLOY_WAIT_TIMEOUT_SECONDS" mongo postgres redis; then + echo "Failed to start backing services; no application containers were replaced." >&2 + print_retry_hint + exit 1 +fi + +if ! "${COMPOSE[@]}" run --rm --no-deps migrate; then + echo "PostgreSQL migration failed; application containers were not replaced." >&2 + print_retry_hint + exit 1 +fi + +declare -A rollback_image_ids=() +declare -A rollback_tags=() +deploy_id="$(date -u +%Y%m%dT%H%M%SZ)-$$" + +snapshot_service_image() { + local service="$1" + local container_id + local image_id + local rollback_tag + + container_id="$("${COMPOSE[@]}" ps -q "$service")" + if [ -z "$container_id" ]; then + echo "No running $service container found; this service has no rollback image." + return 0 + fi + + image_id="$(docker inspect --format '{{.Image}}' "$container_id")" + rollback_tag="rollback-${service}-${previous_commit:0:12}-${deploy_id}" + docker image tag "$image_id" "letscube-app:$rollback_tag" + + rollback_image_ids["$service"]="$image_id" + rollback_tags["$service"]="$rollback_tag" + echo "Saved $service rollback image $image_id as letscube-app:$rollback_tag" +} + +reload_nginx() { + local compose_variable="${1:-COMPOSE}" + local -n compose_command="$compose_variable" + local container_id + + container_id="$("${compose_command[@]}" ps --status running -q nginx)" + if [ -z "$container_id" ]; then + echo "nginx is not running; starting it without replacing dependencies." + "${compose_command[@]}" up -d --no-deps --wait \ + --wait-timeout "$DEPLOY_WAIT_TIMEOUT_SECONDS" nginx || return 1 + fi + + "${compose_command[@]}" exec -T nginx nginx -s reload +} + +rollback_services() { + local services=("$@") + local original_tag="$APP_IMAGE_TAG" + local rollback_failed="false" + local restored_any="false" + local index + local service + local rollback_tag + + echo "Restoring application services from their pre-deploy images." >&2 + + for ((index=${#services[@]} - 1; index >= 0; index--)); do + service="${services[$index]}" + rollback_tag="${rollback_tags[$service]-}" + + if [ -z "$rollback_tag" ]; then + echo "Cannot restore $service: there was no running pre-deploy container." >&2 + rollback_failed="true" + continue + fi + + export APP_IMAGE_TAG="$rollback_tag" + if "${ROLLBACK_COMPOSE[@]}" up -d --no-deps --wait \ + --wait-timeout "$DEPLOY_WAIT_TIMEOUT_SECONDS" "$service"; then + echo "Restored $service from ${rollback_image_ids[$service]}." >&2 + restored_any="true" + else + echo "Failed to restore $service. Recent logs:" >&2 + "${ROLLBACK_COMPOSE[@]}" logs --tail=120 "$service" >&2 || true + rollback_failed="true" + fi + done + + export APP_IMAGE_TAG="$original_tag" + + if [ "$restored_any" = "true" ] && ! reload_nginx ROLLBACK_COMPOSE; then + echo "Application service rollback completed, but nginx reload failed." >&2 + "${ROLLBACK_COMPOSE[@]}" logs --tail=120 nginx >&2 || true + rollback_failed="true" + fi + + [ "$rollback_failed" = "false" ] +} + +# Snapshot every selected service before the first replacement. If a later +# service fails, the entire application portion of this deploy can return to a +# consistent set of exact pre-deploy images. +for service in "${selected_services[@]}"; do + snapshot_service_image "$service" +done + +attempted_services=() + +last_service_index=$((${#selected_services[@]} - 1)) + +for service_index in "${!selected_services[@]}"; do + service="${selected_services[$service_index]}" + attempted_services+=("$service") + + if ! "${COMPOSE[@]}" up -d --no-deps --wait \ + --wait-timeout "$DEPLOY_WAIT_TIMEOUT_SECONDS" "$service"; then + echo "$service deployment failed. Recent logs:" >&2 + "${COMPOSE[@]}" logs --tail=120 "$service" >&2 || true + rollback_services "${attempted_services[@]}" || \ + echo "Automatic rollback was incomplete; manual intervention is required." >&2 + print_retry_hint + exit 1 + fi + + # On a bootstrap deployment, nginx cannot start until both application + # services exist because its config resolves both upstream names. Existing + # nginx containers are still reloaded after each selected replacement. + if [ "$service_index" -lt "$last_service_index" ] \ + && [ -z "$("${COMPOSE[@]}" ps --status running -q nginx)" ]; then + echo "nginx is not running; deferring startup until selected services are ready." + continue + fi + + if ! reload_nginx COMPOSE; then + echo "Failed to reload nginx after the $service deployment. Recent logs:" >&2 + "${COMPOSE[@]}" logs --tail=120 "$service" nginx >&2 || true + rollback_services "${attempted_services[@]}" || \ + echo "Automatic rollback was incomplete; manual intervention is required." >&2 + print_retry_hint + exit 1 + fi +done + +echo "Deployment complete: $resolved_target now uses letscube-app:$current_commit" "${COMPOSE[@]}" ps diff --git a/scripts/test-deploy-classifier.sh b/scripts/test-deploy-classifier.sh new file mode 100755 index 0000000..70368b2 --- /dev/null +++ b/scripts/test-deploy-classifier.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +CLASSIFIER="$SCRIPT_DIR/classify-deploy-target.sh" + +assert_target() { + local expected="$1" + shift + + local actual + actual="$(bash "$CLASSIFIER" "$@")" + if [ "$actual" != "$expected" ]; then + echo "Expected '$expected' for: $*" >&2 + echo "Got '$actual'" >&2 + exit 1 + fi +} + +assert_target none +assert_target none README.md docs/operations.md .github/workflows/ci.yml +assert_target api client/src/App.jsx client/src/styles.css +assert_target all client/src/lib/protocol.js +assert_target all client/src/lib/protocol.json +assert_target all client/src/lib/events.json +assert_target all server/socket/index.js +assert_target all yarn.lock +assert_target all client/src/App.jsx compose.yml +assert_target all unknown-runtime-file + +echo "Deploy target classifier checks passed." diff --git a/scripts/test-deploy.sh b/scripts/test-deploy.sh new file mode 100755 index 0000000..ef82299 --- /dev/null +++ b/scripts/test-deploy.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash +set -euo pipefail + +log_command() { + local command="$1" + shift + + { + printf '%s' "$command" + printf ' %q' "$@" + printf '\n' + } >> "$DEPLOY_TEST_LOG" +} + +fake_git() { + log_command git "$@" + + case "$1" in + rev-parse) + if [ -e "$FAKE_GIT_STATE" ]; then + printf '%s\n' "$FAKE_CURRENT_COMMIT" + else + : > "$FAKE_GIT_STATE" + printf '%s\n' "$FAKE_PREVIOUS_COMMIT" + fi + ;; + fetch|merge) + ;; + diff) + if [ -n "${FAKE_CHANGED_PATH:-}" ]; then + printf '%s\0' "$FAKE_CHANGED_PATH" + fi + ;; + show) + printf '%s\n' \ + 'name: letscube' \ + 'services:' \ + ' api:' \ + ' image: letscube-app:${APP_IMAGE_TAG:-local}' \ + ' socket:' \ + ' image: letscube-app:${APP_IMAGE_TAG:-local}' + ;; + *) + echo "Unexpected fake git command: $*" >&2 + return 1 + ;; + esac +} + +fake_docker() { + log_command docker "$@" + + case "$1" in + inspect) + printf 'sha256:old-image\n' + return 0 + ;; + image) + return 0 + ;; + compose) + shift + ;; + *) + echo "Unexpected fake docker command: $*" >&2 + return 1 + ;; + esac + + local args=("$@") + local subcommand="" + local service="${args[${#args[@]} - 1]}" + local is_rollback="false" + local arg + + for arg in "${args[@]}"; do + case "$arg" in + build|exec|logs|ps|run|up) + if [ -z "$subcommand" ]; then + subcommand="$arg" + fi + ;; + esac + if [[ "$arg" == */letscube-deploy.*/* ]]; then + is_rollback="true" + fi + done + + if [ "$subcommand" = "ps" ]; then + case "$service" in + api) + [ -z "${FAKE_RUNNING_API:-}" ] || printf '%s\n' "$FAKE_RUNNING_API" + ;; + socket) + [ -z "${FAKE_RUNNING_SOCKET:-}" ] || printf '%s\n' "$FAKE_RUNNING_SOCKET" + ;; + nginx) + [ -z "${FAKE_RUNNING_NGINX:-}" ] || printf '%s\n' "$FAKE_RUNNING_NGINX" + ;; + esac + return 0 + fi + + if [ "$subcommand" = "up" ] && [ "$service" = "socket" ]; then + if [ "$is_rollback" = "true" ]; then + { + printf 'APP_IMAGE_TAG=%s\n' "${APP_IMAGE_TAG:-}" + printf '%s\n' "${args[*]}" + } > "$FAKE_ROLLBACK_MARKER" + elif [ "${FAKE_FAIL_CURRENT_SOCKET:-false}" = "true" ]; then + return 1 + fi + fi +} + +case "$(basename -- "$0")" in + git) + fake_git "$@" + exit + ;; + docker) + fake_docker "$@" + exit + ;; +esac + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +DEPLOY_SCRIPT="$SCRIPT_DIR/deploy.sh" +CLASSIFIER_SCRIPT="$SCRIPT_DIR/classify-deploy-target.sh" +TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/letscube-deploy-test.XXXXXX")" + +cleanup() { + rm -rf -- "$TEST_ROOT" +} + +trap cleanup EXIT + +FAKE_BIN="$TEST_ROOT/bin" +mkdir -p "$FAKE_BIN" +ln -s "$SCRIPT_DIR/test-deploy.sh" "$FAKE_BIN/git" +ln -s "$SCRIPT_DIR/test-deploy.sh" "$FAKE_BIN/docker" + +prepare_app() { + local app_dir="$1" + + mkdir -p "$app_dir/scripts" + cp "$DEPLOY_SCRIPT" "$app_dir/scripts/deploy.sh" + cp "$CLASSIFIER_SCRIPT" "$app_dir/scripts/classify-deploy-target.sh" +} + +assert_contains() { + local value="$1" + local expected="$2" + + if [[ "$value" != *"$expected"* ]]; then + echo "Expected output to contain: $expected" >&2 + exit 1 + fi +} + +command_line() { + local file="$1" + local expected="$2" + local number=0 + local line + + while IFS= read -r line; do + number=$((number + 1)) + if [[ "$line" == *"$expected"* ]]; then + printf '%s\n' "$number" + return 0 + fi + done < "$file" + + echo "Missing command containing: $expected" >&2 + return 1 +} + +run_bootstrap_test() { + local scenario="$TEST_ROOT/bootstrap" + local app_dir="$scenario/app" + local log="$scenario/commands.log" + local output="$scenario/output.log" + local git_state="$scenario/git-state" + local rollback_marker="$scenario/rollback" + local commit="1111111111111111111111111111111111111111" + local backing_line + local migration_line + local socket_line + local api_line + + mkdir -p "$scenario/tmp" + prepare_app "$app_dir" + + PATH="$FAKE_BIN:$PATH" \ + APP_DIR="$app_dir" \ + DEPLOY_TEST_LOG="$log" \ + DEPLOY_WAIT_TIMEOUT_SECONDS=17 \ + ENV_FILE=.env.prod \ + FAKE_CURRENT_COMMIT="$commit" \ + FAKE_GIT_STATE="$git_state" \ + FAKE_PREVIOUS_COMMIT="$commit" \ + FAKE_ROLLBACK_MARKER="$rollback_marker" \ + TMPDIR="$scenario/tmp" \ + bash "$app_dir/scripts/deploy.sh" > "$output" 2>&1 + + assert_contains "$(<"$output")" 'Missing runtime services: api socket nginx; deploying all.' + assert_contains "$(<"$output")" '(target: all)' + + backing_line="$(command_line "$log" 'up -d --no-recreate --wait --wait-timeout 17 mongo postgres redis')" + migration_line="$(command_line "$log" 'run --rm --no-deps migrate')" + socket_line="$(command_line "$log" 'up -d --no-deps --wait --wait-timeout 17 socket')" + api_line="$(command_line "$log" 'up -d --no-deps --wait --wait-timeout 17 api')" + + if [ "$backing_line" -ge "$migration_line" ]; then + echo 'Migrations ran before backing services became ready.' >&2 + exit 1 + fi + if [ "$socket_line" -ge "$api_line" ]; then + echo 'Bootstrap did not deploy socket before API.' >&2 + exit 1 + fi +} + +run_rollback_test() { + local scenario="$TEST_ROOT/rollback" + local app_dir="$scenario/app" + local log="$scenario/commands.log" + local output="$scenario/output.log" + local git_state="$scenario/git-state" + local rollback_marker="$scenario/rollback" + local previous_commit="1111111111111111111111111111111111111111" + local current_commit="2222222222222222222222222222222222222222" + local rollback_details + + mkdir -p "$scenario/tmp" + prepare_app "$app_dir" + + if PATH="$FAKE_BIN:$PATH" \ + APP_DIR="$app_dir" \ + DEPLOY_TEST_LOG="$log" \ + DEPLOY_WAIT_TIMEOUT_SECONDS=17 \ + ENV_FILE=.env.prod \ + FAKE_CHANGED_PATH=server/index.js \ + FAKE_CURRENT_COMMIT="$current_commit" \ + FAKE_FAIL_CURRENT_SOCKET=true \ + FAKE_GIT_STATE="$git_state" \ + FAKE_PREVIOUS_COMMIT="$previous_commit" \ + FAKE_ROLLBACK_MARKER="$rollback_marker" \ + FAKE_RUNNING_API=api-container \ + FAKE_RUNNING_NGINX=nginx-container \ + FAKE_RUNNING_SOCKET=socket-container \ + TMPDIR="$scenario/tmp" \ + bash "$app_dir/scripts/deploy.sh" > "$output" 2>&1; then + echo 'Expected the simulated socket deployment to fail.' >&2 + exit 1 + fi + + if [ ! -f "$rollback_marker" ]; then + echo 'Rollback did not recreate the socket service.' >&2 + exit 1 + fi + + rollback_details="$(<"$rollback_marker")" + assert_contains "$rollback_details" "--project-directory $app_dir" + assert_contains "$rollback_details" '/letscube-deploy.' + assert_contains "$rollback_details" 'APP_IMAGE_TAG=rollback-socket-111111111111-' + assert_contains "$(<"$log")" "git show $previous_commit:compose.yml" + assert_contains "$(<"$log")" "git show $previous_commit:compose.prod.yml" +} + +run_bootstrap_test +run_rollback_test + +echo 'Deploy script checks passed.' 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..463f612 100644 --- a/server/config/default.json +++ b/server/config/default.json @@ -3,9 +3,17 @@ "port": 8080 }, "socketio": { - "port": 9000 + "port": 9000, + "reconnectGraceMs": 60000 }, "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/health.js b/server/health.js new file mode 100644 index 0000000..e10c3a8 --- /dev/null +++ b/server/health.js @@ -0,0 +1,72 @@ +const runCheck = async (check, timeoutMs) => { + let timer; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(false), timeoutMs); + if (timer.unref) { + timer.unref(); + } + }); + + const result = await Promise.race([ + Promise.resolve() + .then(check) + .then((healthy) => healthy !== false) + .catch(() => false), + timeout, + ]); + + clearTimeout(timer); + return result; +}; + +const createHealthReporter = ({ + service, + checks, + checkTimeoutMs = 2000, + now = () => new Date(), + uptime = () => process.uptime(), +}) => async () => { + const checkResults = await Promise.all(Object.entries(checks).map(async ([name, value]) => { + const descriptor = typeof value === 'function' ? { check: value } : value; + const required = descriptor.required !== false; + const status = await runCheck(descriptor.check, checkTimeoutMs) ? 'ok' : 'error'; + return { name, required, status }; + })); + const normalizedChecks = Object.fromEntries(checkResults.map(({ name, status }) => ( + [name, status] + ))); + const requiredCheckFailed = checkResults.some(({ required, status }) => ( + required && status === 'error' + )); + const optionalCheckFailed = checkResults.some(({ required, status }) => ( + !required && status === 'error' + )); + let status = 'ok'; + if (requiredCheckFailed) { + status = 'error'; + } else if (optionalCheckFailed) { + status = 'degraded'; + } + + return { + status, + service, + timestamp: now().toISOString(), + uptimeSeconds: Math.floor(uptime()), + checks: normalizedChecks, + }; +}; + +const createHealthHandler = (reportHealth) => async (_req, res) => { + const report = await reportHealth(); + + res.statusCode = report.status === 'error' ? 503 : 200; + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(JSON.stringify(report)); +}; + +module.exports = { + createHealthHandler, + createHealthReporter, +}; diff --git a/server/health.test.js b/server/health.test.js new file mode 100644 index 0000000..48880b2 --- /dev/null +++ b/server/health.test.js @@ -0,0 +1,148 @@ +/* eslint-env jest */ + +const { createHealthHandler, createHealthReporter } = require('./health'); + +describe('health reporting', () => { + const fixedTime = new Date('2026-07-10T12:00:00.000Z'); + + it('reports healthy dependencies', async () => { + const reportHealth = createHealthReporter({ + service: 'api', + checks: { + mongodb: () => true, + postgres: async () => true, + }, + now: () => fixedTime, + uptime: () => 12.9, + }); + + await expect(reportHealth()).resolves.toEqual({ + status: 'ok', + service: 'api', + timestamp: fixedTime.toISOString(), + uptimeSeconds: 12, + checks: { + mongodb: 'ok', + postgres: 'ok', + }, + }); + }); + + it('reports failed and timed-out dependencies without exposing errors', async () => { + const reportHealth = createHealthReporter({ + service: 'socket', + checks: { + mongodb: () => { + throw new Error('connection details'); + }, + redis: () => new Promise(() => {}), + }, + checkTimeoutMs: 1, + now: () => fixedTime, + uptime: () => 3, + }); + + await expect(reportHealth()).resolves.toEqual({ + status: 'error', + service: 'socket', + timestamp: fixedTime.toISOString(), + uptimeSeconds: 3, + checks: { + mongodb: 'error', + redis: 'error', + }, + }); + }); + + it('reports degraded when only an optional dependency fails', async () => { + const reportHealth = createHealthReporter({ + service: 'socket', + checks: { + mongodb: () => true, + postgres: { + required: false, + check: () => false, + }, + redis: () => true, + }, + now: () => fixedTime, + uptime: () => 5, + }); + + await expect(reportHealth()).resolves.toEqual({ + status: 'degraded', + service: 'socket', + timestamp: fixedTime.toISOString(), + uptimeSeconds: 5, + checks: { + mongodb: 'ok', + postgres: 'error', + redis: 'ok', + }, + }); + }); + + it('reports error when a required dependency fails alongside an optional one', async () => { + const reportHealth = createHealthReporter({ + service: 'socket', + checks: { + mongodb: () => false, + postgres: { + required: false, + check: () => false, + }, + }, + now: () => fixedTime, + uptime: () => 5, + }); + + await expect(reportHealth()).resolves.toMatchObject({ + status: 'error', + checks: { + mongodb: 'error', + postgres: 'error', + }, + }); + }); + + it('returns 503 and disables caching when a health check fails', async () => { + const report = { + status: 'error', + service: 'api', + checks: { mongodb: 'error' }, + }; + const response = { + setHeader: jest.fn(), + end: jest.fn(), + }; + const handler = createHealthHandler(jest.fn().mockResolvedValue(report)); + + await handler({}, response); + + expect(response.statusCode).toBe(503); + expect(response.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store'); + expect(response.setHeader).toHaveBeenCalledWith( + 'Content-Type', + 'application/json; charset=utf-8', + ); + expect(response.end).toHaveBeenCalledWith(JSON.stringify(report)); + }); + + it('returns 200 for degraded health', async () => { + const report = { + status: 'degraded', + service: 'api', + checks: { postgres: 'error' }, + }; + const response = { + setHeader: jest.fn(), + end: jest.fn(), + }; + const handler = createHealthHandler(jest.fn().mockResolvedValue(report)); + + await handler({}, response); + + expect(response.statusCode).toBe(200); + expect(response.end).toHaveBeenCalledWith(JSON.stringify(report)); + }); +}); diff --git a/server/index.js b/server/index.js index 4a85e6e..95d74fa 100644 --- a/server/index.js +++ b/server/index.js @@ -7,11 +7,12 @@ const passport = require('passport'); const config = require('./runtimeConfig'); const { connect } = require('./database'); +const { createHealthHandler, createHealthReporter } = require('./health'); +const { initializePostgres, pool, startPostgresMaintenance } = require('./postgres'); const session = require('./middlewares/session'); const logger = require('./logger'); const auth = require('./auth'); const api = require('./api'); -const { Room } = require('./models'); Error.stackTraceLimit = 100; @@ -25,6 +26,26 @@ const init = async () => { app.use(express.urlencoded({ extended: true })); // for parsing application/x-www-form-urlencoded const mongoose = await connect(); + await initializePostgres(); + startPostgresMaintenance(); + + const reportHealth = createHealthReporter({ + service: 'api', + checks: { + mongodb: () => mongoose.connection.readyState === 1, + postgres: { + required: false, + check: async () => { + if (config.postgres.enabled) { + await pool.query('SELECT 1'); + } + return true; + }, + }, + }, + }); + + app.get('/health/api', createHealthHandler(reportHealth)); /* Logging */ @@ -98,26 +119,3 @@ try { } catch (e) { logger.error(e); } - -/* eslint-disable no-await-in-loop */ -async function asyncForEach(array, callback) { - for (let index = 0; index < array.length; index += 1) { - await callback(array[index], index, array); - } -} - -process.on('SIGINT', () => { - Room.find().then(async (rooms) => { - try { - await asyncForEach(rooms, async (room) => { - await asyncForEach(room.users, async (user) => { - await room.dropUser(user); - }); - }); - } catch (e) { - logger.error(e); - } - - process.exit(0); - }); -}); 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..366f6e8 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; @@ -17,6 +18,7 @@ const Result = new mongoose.Schema({ required: true, }, penalties: Object, + submissionId: String, }, { timestamps: true, }); @@ -326,5 +328,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..131ad2b 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,94 @@ describe('room security helpers', () => { expect(room.expireAt.getTime()).toBeGreaterThanOrEqual(before + 10 * 60 * 1000); expect(room.expireAt.getTime()).toBeLessThanOrEqual(Date.now() + 10 * 60 * 1000); }); + + it('stores an optional result submission id', () => { + const room = RoomModel.hydrate({ + _id: '507f1f77bcf86cd799439011', + name: 'Practice room', + attempts: [{ + id: 0, + scrambles: ['R U R\''], + results: { + 1234: { + time: 12000, + penalties: {}, + submissionId: 'submission-123', + }, + }, + }], + }); + + expect(room.attempts[0].results.get('1234').submissionId).toBe('submission-123'); + }); + + 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..16ed0f2 100644 --- a/server/runtimeConfig.js +++ b/server/runtimeConfig.js @@ -13,6 +13,20 @@ 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 parseNonNegativeInteger = (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'; @@ -49,13 +63,17 @@ module.exports = { socketio: { ...baseConfig.socketio, port: parsePort(process.env.SOCKETIO_PORT, baseConfig.socketio.port), + reconnectGraceMs: parseNonNegativeInteger( + process.env.ROOM_RECONNECT_GRACE_MS, + baseConfig.socketio.reconnectGraceMs, + ), }, mongodb: process.env.MONGO_URL || process.env.MONGODB_URI || baseConfig.mongodb, redis, 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 +89,29 @@ 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), + }, + grandPrix: { + enabled: process.env.GRAND_PRIX_ENABLED === 'true', + }, + 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/runtimeConfig.test.js b/server/runtimeConfig.test.js new file mode 100644 index 0000000..c18e344 --- /dev/null +++ b/server/runtimeConfig.test.js @@ -0,0 +1,32 @@ +/* eslint-env jest */ + +// eslint-disable-next-line global-require +const loadRuntimeConfig = () => require('./runtimeConfig'); + +describe('Grand Prix runtime configuration', () => { + const originalValue = process.env.GRAND_PRIX_ENABLED; + + afterEach(() => { + if (originalValue === undefined) { + delete process.env.GRAND_PRIX_ENABLED; + } else { + process.env.GRAND_PRIX_ENABLED = originalValue; + } + jest.resetModules(); + }); + + it('is disabled by default', () => { + delete process.env.GRAND_PRIX_ENABLED; + + expect(loadRuntimeConfig().grandPrix.enabled).toBe(false); + }); + + it('is enabled only by the explicit lowercase true value', () => { + process.env.GRAND_PRIX_ENABLED = 'true'; + expect(loadRuntimeConfig().grandPrix.enabled).toBe(true); + + jest.resetModules(); + process.env.GRAND_PRIX_ENABLED = 'TRUE'; + expect(loadRuntimeConfig().grandPrix.enabled).toBe(false); + }); +}); diff --git a/server/socket/index.js b/server/socket/index.js index 18a5d10..dc4ebff 100644 --- a/server/socket/index.js +++ b/server/socket/index.js @@ -7,6 +7,8 @@ const expressSocketSession = require('express-socket.io-session'); const config = require('../runtimeConfig'); const session = require('../middlewares/session'); const { connect } = require('../database'); +const { createHealthHandler, createHealthReporter } = require('../health'); +const { initializePostgres, pool } = require('../postgres'); const logger = require('../logger'); const loggerMiddleware = require('./middlewares/logger'); const authenticateMiddleware = require('./middlewares/authenticate'); @@ -49,6 +51,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, @@ -63,6 +66,31 @@ const init = async () => { io.adapter(createAdapter(pubClient, subClient)); + const reportHealth = createHealthReporter({ + service: 'socket', + checks: { + mongodb: () => mongoose.connection.readyState === 1, + postgres: { + required: false, + check: async () => { + if (config.postgres.enabled) { + await pool.query('SELECT 1'); + } + return true; + }, + }, + redis: async () => pubClient.status === 'ready' + && subClient.status === 'ready' + && (await pubClient.ping()) === 'PONG', + }, + }); + const handleHealth = createHealthHandler(reportHealth); + server.on('request', (req, res) => { + if (req.method === 'GET' && req.url.split('?')[0] === '/health/socket') { + handleHealth(req, res); + } + }); + const middlewares = [ expressSocketSession(session(mongoose), { autoSave: true, @@ -72,7 +100,7 @@ const init = async () => { ]; initRooms(io, middlewares); - initDefault(io, middlewares); + initDefault(io, middlewares, reportHealth); watchNamespaceErrors(io, '/'); watchNamespaceErrors(io, '/rooms'); 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/lib/reconnectGrace.js b/server/socket/lib/reconnectGrace.js new file mode 100644 index 0000000..162b2ed --- /dev/null +++ b/server/socket/lib/reconnectGrace.js @@ -0,0 +1,98 @@ +const timerKey = (roomId, userId) => `${roomId}:${userId}`; + +const createReconnectGrace = ({ + graceMs, + hasActiveSockets, + finalizeDeparture, + listActiveUsers, + logger, + setTimer = setTimeout, + clearTimer = clearTimeout, +}) => { + const timers = new Map(); + const finalizationQueues = new Map(); + + const cancel = (roomId, userId) => { + const key = timerKey(roomId, userId); + const timer = timers.get(key); + if (timer) { + clearTimer(timer); + timers.delete(key); + } + + const finalization = finalizationQueues.get(roomId.toString()); + return finalization + ? finalization.then(() => true, () => true) + : Promise.resolve(false); + }; + + const enqueueFinalization = (departure, requireInactive) => { + const roomKey = departure.roomId.toString(); + const previous = finalizationQueues.get(roomKey) || Promise.resolve(); + const current = previous.catch(() => {}).then(async () => { + if (requireInactive + && await hasActiveSockets(departure.userId, departure.roomId)) { + return false; + } + + return finalizeDeparture(departure); + }); + + finalizationQueues.set(roomKey, current); + current.finally(() => { + if (finalizationQueues.get(roomKey) === current) { + finalizationQueues.delete(roomKey); + } + }).catch(() => {}); + + return current; + }; + + const finalizeIfInactive = (departure) => enqueueFinalization(departure, true); + const finalize = (departure) => enqueueFinalization(departure, false); + + const schedule = (departure) => { + cancel(departure.roomId, departure.userId); + + const key = timerKey(departure.roomId, departure.userId); + const timer = setTimer(() => { + timers.delete(key); + finalizeIfInactive(departure).catch((err) => logger.error(err)); + }, graceMs); + + if (timer && timer.unref) { + timer.unref(); + } + + timers.set(key, timer); + }; + + const reconcile = async () => { + const activeUsers = await listActiveUsers(); + await Promise.all(activeUsers.map((departure) => finalizeIfInactive(departure))); + }; + + const startReconciliation = () => { + const timer = setTimer(() => { + reconcile().catch((err) => logger.error(err)); + }, graceMs); + + if (timer && timer.unref) { + timer.unref(); + } + + return timer; + }; + + return { + cancel, + finalize, + reconcile, + schedule, + startReconciliation, + }; +}; + +module.exports = { + createReconnectGrace, +}; diff --git a/server/socket/lib/reconnectGrace.test.js b/server/socket/lib/reconnectGrace.test.js new file mode 100644 index 0000000..0d5cf8b --- /dev/null +++ b/server/socket/lib/reconnectGrace.test.js @@ -0,0 +1,187 @@ +/* eslint-env jest */ + +const { createReconnectGrace } = require('./reconnectGrace'); + +describe('reconnect grace', () => { + const departure = { + roomId: 'room-123', + userId: 101, + connectionId: 'socket-123', + leaveReason: 'disconnect', + }; + let hasActiveSockets; + let finalizeDeparture; + let listActiveUsers; + let logger; + + const createManager = (graceMs = 30000) => createReconnectGrace({ + graceMs, + hasActiveSockets, + finalizeDeparture, + listActiveUsers, + logger, + }); + + beforeEach(() => { + jest.useFakeTimers(); + hasActiveSockets = jest.fn().mockResolvedValue(false); + finalizeDeparture = jest.fn().mockResolvedValue(true); + listActiveUsers = jest.fn().mockResolvedValue([]); + logger = { error: jest.fn() }; + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('keeps the user in the room until the reconnect grace expires', async () => { + let markFinalized; + const finalized = new Promise((resolve) => { + markFinalized = resolve; + }); + finalizeDeparture.mockImplementationOnce(async (value) => { + markFinalized(value); + return true; + }); + const manager = createManager(); + + manager.schedule(departure); + jest.advanceTimersByTime(29999); + await Promise.resolve(); + + expect(finalizeDeparture).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1); + await finalized; + + expect(hasActiveSockets).toHaveBeenCalledWith(101, 'room-123'); + expect(finalizeDeparture).toHaveBeenCalledWith(departure); + }); + + it('cancels pending cleanup when the user reconnects', async () => { + const manager = createManager(); + + manager.schedule(departure); + manager.cancel(departure.roomId, departure.userId); + jest.runAllTimers(); + await Promise.resolve(); + + expect(hasActiveSockets).not.toHaveBeenCalled(); + expect(finalizeDeparture).not.toHaveBeenCalled(); + }); + + it('lets a reconnect wait for cleanup already in progress', async () => { + let finishFinalization; + let markFinalizationStarted; + const finalizationStarted = new Promise((resolve) => { + markFinalizationStarted = resolve; + }); + finalizeDeparture.mockImplementationOnce(() => new Promise((resolve) => { + markFinalizationStarted(); + finishFinalization = resolve; + })); + const manager = createManager(); + + manager.schedule(departure); + jest.runAllTimers(); + await finalizationStarted; + + let reconnectReleased = false; + const reconnect = manager.cancel(departure.roomId, departure.userId) + .then((waited) => { + reconnectReleased = true; + return waited; + }); + await Promise.resolve(); + + expect(reconnectReleased).toBe(false); + + finishFinalization(true); + await expect(reconnect).resolves.toBe(true); + }); + + it('finalizes an explicit departure without waiting for socket inactivity', async () => { + const manager = createManager(); + + await manager.finalize({ ...departure, leaveReason: 'explicit' }); + + expect(hasActiveSockets).not.toHaveBeenCalled(); + expect(finalizeDeparture).toHaveBeenCalledWith({ + ...departure, + leaveReason: 'explicit', + }); + }); + + it('does not remove a user who has another active socket', async () => { + let markChecked; + const checked = new Promise((resolve) => { + markChecked = resolve; + }); + hasActiveSockets.mockImplementationOnce(async () => { + markChecked(); + return true; + }); + const manager = createManager(); + + manager.schedule(departure); + jest.runAllTimers(); + await checked; + + expect(finalizeDeparture).not.toHaveBeenCalled(); + }); + + it('reconciles users left active by a replaced socket process', async () => { + const connected = { ...departure, userId: 202 }; + listActiveUsers.mockResolvedValue([departure, connected]); + hasActiveSockets.mockImplementation(async (userId) => userId === 202); + const manager = createManager(); + + await manager.reconcile(); + + expect(finalizeDeparture).toHaveBeenCalledTimes(1); + expect(finalizeDeparture).toHaveBeenCalledWith(departure); + }); + + it('serializes cleanup for users in the same room', async () => { + let finishFirstDeparture; + let markFirstStarted; + const firstStarted = new Promise((resolve) => { + markFirstStarted = resolve; + }); + const secondDeparture = { ...departure, userId: 202 }; + listActiveUsers.mockResolvedValue([departure, secondDeparture]); + finalizeDeparture + .mockImplementationOnce(() => new Promise((resolve) => { + markFirstStarted(); + finishFirstDeparture = resolve; + })) + .mockResolvedValueOnce(true); + const manager = createManager(); + + const reconciliation = manager.reconcile(); + await firstStarted; + + expect(finalizeDeparture).toHaveBeenCalledTimes(1); + + finishFirstDeparture(true); + await reconciliation; + + expect(finalizeDeparture).toHaveBeenCalledTimes(2); + expect(finalizeDeparture).toHaveBeenLastCalledWith(secondDeparture); + }); + + it('delays startup reconciliation by the same grace window', async () => { + listActiveUsers.mockResolvedValue([departure]); + const manager = createManager(45000); + + manager.startReconciliation(); + jest.advanceTimersByTime(44999); + expect(listActiveUsers).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1); + await Promise.resolve(); + await Promise.resolve(); + + expect(listActiveUsers).toHaveBeenCalledTimes(1); + }); +}); diff --git a/server/socket/lib/resultSubmission.js b/server/socket/lib/resultSubmission.js new file mode 100644 index 0000000..4a1c4d9 --- /dev/null +++ b/server/socket/lib/resultSubmission.js @@ -0,0 +1,219 @@ +const MAX_ATTEMPT_KEY_LENGTH = 128; +const MAX_SUBMISSION_ID_LENGTH = 128; + +class ResultSubmissionError extends Error { + constructor(statusCode, message, retryable, cause) { + super(message); + this.name = 'ResultSubmissionError'; + this.statusCode = statusCode; + this.retryable = retryable; + this.cause = cause; + } + + toResponse() { + return { + statusCode: this.statusCode, + message: this.message, + retryable: this.retryable, + }; + } +} + +const submissionError = (statusCode, message, retryable = false, cause) => ( + new ResultSubmissionError(statusCode, message, retryable, cause) +); + +const normalizedPenalties = (penalties = {}) => ({ + AUF: !!penalties.AUF, + DNF: !!penalties.DNF, + inspection: !!penalties.inspection, +}); + +const resultsMatch = (existingResult, incomingResult) => ( + existingResult.time === incomingResult.time + && Object.entries(normalizedPenalties(existingResult.penalties)).every(([key, value]) => ( + normalizedPenalties(incomingResult.penalties)[key] === value + )) +); + +const validateSubmission = ({ + attemptId, attemptKey, result, submissionId, +}) => { + if (!Number.isInteger(attemptId) || attemptId < 0) { + throw submissionError(400, 'Invalid ID for attempt submission'); + } + + if (!result || !Number.isFinite(result.time)) { + throw submissionError(400, 'Invalid result submission'); + } + + if (result.penalties !== undefined + && result.penalties !== null + && (typeof result.penalties !== 'object' || Array.isArray(result.penalties))) { + throw submissionError(400, 'Invalid result penalties'); + } + + if (submissionId !== undefined + && (typeof submissionId !== 'string' + || submissionId.trim().length === 0 + || submissionId.length > MAX_SUBMISSION_ID_LENGTH)) { + throw submissionError(400, 'Invalid result submission ID'); + } + + if (attemptKey !== undefined + && (typeof attemptKey !== 'string' + || attemptKey.trim().length === 0 + || attemptKey.length > MAX_ATTEMPT_KEY_LENGTH)) { + throw submissionError(400, 'Invalid result attempt key'); + } + + return { + attemptId, + attemptKey: attemptKey === undefined ? undefined : attemptKey.trim(), + result: { + time: result.time, + penalties: { ...(result.penalties || {}) }, + }, + submissionId: submissionId === undefined ? undefined : submissionId.trim(), + }; +}; + +const createResultSubmissionService = ({ + advanceRoom = async (room) => room, + fetchRoom, +}) => { + const queues = new Map(); + + const enqueue = (key, task) => { + const previous = queues.get(key) || Promise.resolve(); + const current = previous.catch(() => {}).then(task); + queues.set(key, current); + current.finally(() => { + if (queues.get(key) === current) { + queues.delete(key); + } + }).catch(() => {}); + return current; + }; + + const submit = ({ + roomId, + userId, + attemptId, + attemptKey, + result, + submissionId, + onSaved = () => {}, + }) => { + const submission = validateSubmission({ + attemptId, attemptKey, result, submissionId, + }); + const userKey = userId.toString(); + const queueKey = roomId.toString(); + + return enqueue(queueKey, async () => { + try { + const room = await fetchRoom(roomId); + if (!room) { + throw submissionError(404, 'Could not find room for result submission'); + } + + if (!room.inRoom.get(userKey) || room.banned.get(userKey)) { + throw submissionError(403, 'Not authorized to submit a result to this room'); + } + + const attempt = room.attempts[submission.attemptId]; + if (!attempt) { + throw submissionError(400, 'Invalid ID for attempt submission'); + } + if (submission.attemptKey !== undefined + && (!attempt._id || attempt._id.toString() !== submission.attemptKey)) { + throw submissionError(409, 'Attempt changed before result submission'); + } + + if (room.type === 'grand_prix') { + submission.result.penalties.DNF = submission.result.penalties.DNF + || submission.attemptId < room.attempts.length - 1; + } + + const previousResult = attempt.results.get(userKey); + let outcome; + if (previousResult) { + if (submission.submissionId + && previousResult.submissionId === submission.submissionId) { + outcome = { + room, + result: previousResult, + status: 'duplicate', + submissionId: submission.submissionId, + }; + } else if (submission.submissionId + && !previousResult.submissionId + && resultsMatch(previousResult, submission.result)) { + previousResult.submissionId = submission.submissionId; + if (typeof room.markModified === 'function') { + room.markModified(`attempts.${submission.attemptId}.results.${userKey}`); + } + const savedRoom = await room.save(); + outcome = { + room: savedRoom, + result: savedRoom.attempts[submission.attemptId].results.get(userKey), + status: 'duplicate', + submissionId: submission.submissionId, + }; + } else { + throw submissionError(409, 'A result already exists for this attempt'); + } + } else { + const storedResult = { + ...submission.result, + ...(submission.submissionId ? { submissionId: submission.submissionId } : {}), + }; + attempt.results.set(userKey, storedResult); + if (submission.attemptId === room.attempts.length - 1) { + room.waitingFor.set(userKey, false); + } + + const savedRoom = await room.save(); + outcome = { + room: savedRoom, + result: savedRoom.attempts[submission.attemptId].results.get(userKey), + status: 'saved', + submissionId: submission.submissionId, + }; + await onSaved(outcome); + } + + if (typeof outcome.room.doneWithScramble === 'function' + && outcome.room.doneWithScramble()) { + try { + outcome.room = await advanceRoom(outcome.room); + } catch (err) { + throw submissionError( + 503, + 'Result saved, but failed to advance the room', + true, + err, + ); + } + } + + return outcome; + } catch (err) { + if (err instanceof ResultSubmissionError) { + throw err; + } + throw submissionError(503, 'Failed to save result', true, err); + } + }); + }; + + return { submit }; +}; + +module.exports = { + createResultSubmissionService, + ResultSubmissionError, + resultsMatch, + validateSubmission, +}; diff --git a/server/socket/lib/resultSubmission.test.js b/server/socket/lib/resultSubmission.test.js new file mode 100644 index 0000000..6ac809b --- /dev/null +++ b/server/socket/lib/resultSubmission.test.js @@ -0,0 +1,397 @@ +/* eslint-env jest */ + +const { + createResultSubmissionService, + ResultSubmissionError, + validateSubmission, +} = require('./resultSubmission'); + +const makeRoom = ({ + attempts = 1, + existingResult, + inRoom = true, + banned = false, + type = 'normal', +} = {}) => { + const room = { + _id: 'room-123', + type, + inRoom: new Map([['42', inRoom], ['43', inRoom]]), + banned: new Map([['42', banned], ['43', banned]]), + waitingFor: new Map([['42', true], ['43', true]]), + attempts: Array.from({ length: attempts }, (_, id) => ({ + _id: `attempt-${id}`, + id, + results: new Map(), + })), + }; + if (existingResult) { + room.attempts[0].results.set('42', existingResult); + } + room.save = jest.fn().mockImplementation(async () => room); + return room; +}; + +const submit = (service, overrides = {}) => service.submit({ + roomId: 'room-123', + userId: 42, + attemptId: 0, + result: { + time: 12345, + penalties: { AUF: false }, + }, + submissionId: 'submission-123', + ...overrides, +}); + +describe('result submission validation', () => { + it('accepts legacy submissions without an id', () => { + expect(validateSubmission({ + attemptId: 0, + result: { time: 1234, penalties: {} }, + })).toEqual({ + attemptId: 0, + attemptKey: undefined, + result: { time: 1234, penalties: {} }, + submissionId: undefined, + }); + }); + + it.each([ + [{ attemptId: -1, result: { time: 1234 } }, 'Invalid ID for attempt submission'], + [{ attemptId: 0, result: { time: Number.NaN } }, 'Invalid result submission'], + [{ attemptId: 0, result: { time: 1234 }, submissionId: '' }, 'Invalid result submission ID'], + [{ attemptId: 0, attemptKey: 123, result: { time: 1234 } }, 'Invalid result attempt key'], + ])('rejects invalid submission payloads', (payload, message) => { + expect(() => validateSubmission(payload)).toThrow(message); + }); +}); + +describe('createResultSubmissionService', () => { + it('persists a result before reporting it as saved', async () => { + const room = makeRoom(); + const service = createResultSubmissionService({ + fetchRoom: jest.fn().mockResolvedValue(room), + }); + + await expect(submit(service)).resolves.toMatchObject({ + room, + status: 'saved', + submissionId: 'submission-123', + }); + expect(room.save).toHaveBeenCalledTimes(1); + expect(room.waitingFor.get('42')).toBe(false); + expect(room.attempts[0].results.get('42')).toMatchObject({ + time: 12345, + penalties: { AUF: false }, + submissionId: 'submission-123', + }); + }); + + it('accepts a matching immutable attempt key', async () => { + const room = makeRoom(); + const service = createResultSubmissionService({ + fetchRoom: jest.fn().mockResolvedValue(room), + }); + + await expect(submit(service, { attemptKey: 'attempt-0' })).resolves.toMatchObject({ + status: 'saved', + }); + }); + + it('rejects a reused attempt number when the immutable attempt key changed', async () => { + const room = makeRoom(); + const service = createResultSubmissionService({ + fetchRoom: jest.fn().mockResolvedValue(room), + }); + + await expect(submit(service, { attemptKey: 'attempt-before-event-reset' })) + .rejects.toMatchObject({ + statusCode: 409, + retryable: false, + }); + expect(room.save).not.toHaveBeenCalled(); + }); + + it('treats the same submission id as a duplicate without saving again', async () => { + const existingResult = { + time: 12345, + penalties: {}, + submissionId: 'submission-123', + }; + const room = makeRoom({ existingResult }); + const service = createResultSubmissionService({ + fetchRoom: jest.fn().mockResolvedValue(room), + }); + + await expect(submit(service)).resolves.toEqual({ + room, + result: existingResult, + status: 'duplicate', + submissionId: 'submission-123', + }); + expect(room.save).not.toHaveBeenCalled(); + }); + + it('adopts an id for an identical result saved by an older server', async () => { + const existingResult = { + time: 12345, + penalties: { DNF: false, inspection: false }, + }; + const room = makeRoom({ existingResult }); + room.markModified = jest.fn(); + const service = createResultSubmissionService({ + fetchRoom: jest.fn().mockResolvedValue(room), + }); + + await expect(submit(service, { + result: { + time: 12345, + penalties: { AUF: false }, + }, + })).resolves.toMatchObject({ + status: 'duplicate', + submissionId: 'submission-123', + }); + expect(existingResult.submissionId).toBe('submission-123'); + expect(room.markModified).toHaveBeenCalledWith('attempts.0.results.42'); + expect(room.save).toHaveBeenCalledTimes(1); + }); + + it('rejects a mismatched result saved by an older server', async () => { + const existingResult = { + time: 11111, + penalties: {}, + }; + const room = makeRoom({ existingResult }); + const service = createResultSubmissionService({ + fetchRoom: jest.fn().mockResolvedValue(room), + }); + + await expect(submit(service)).rejects.toMatchObject({ + statusCode: 409, + retryable: false, + }); + expect(existingResult.submissionId).toBeUndefined(); + expect(room.save).not.toHaveBeenCalled(); + }); + + it('rejects a conflicting result without overwriting it', async () => { + const existingResult = { + time: 11111, + penalties: {}, + submissionId: 'older-submission', + }; + const room = makeRoom({ existingResult }); + const service = createResultSubmissionService({ + fetchRoom: jest.fn().mockResolvedValue(room), + }); + + await expect(submit(service)).rejects.toMatchObject({ + statusCode: 409, + retryable: false, + }); + expect(room.attempts[0].results.get('42')).toBe(existingResult); + expect(room.save).not.toHaveBeenCalled(); + }); + + it('allows submission to an original attempt after the room advances', async () => { + const room = makeRoom({ attempts: 2 }); + const service = createResultSubmissionService({ + fetchRoom: jest.fn().mockResolvedValue(room), + }); + + await expect(submit(service, { attemptId: 0 })).resolves.toMatchObject({ + status: 'saved', + }); + expect(room.attempts[0].results.has('42')).toBe(true); + expect(room.attempts[1].results.has('42')).toBe(false); + expect(room.waitingFor.get('42')).toBe(true); + }); + + it('serializes duplicate submissions in one room', async () => { + const room = makeRoom(); + let finishSave; + let notifySaveStarted; + const saveStarted = new Promise((resolve) => { + notifySaveStarted = resolve; + }); + room.save.mockImplementation(() => new Promise((resolve) => { + finishSave = () => resolve(room); + notifySaveStarted(); + })); + const fetchRoom = jest.fn().mockResolvedValue(room); + const service = createResultSubmissionService({ fetchRoom }); + + const first = submit(service); + await saveStarted; + const second = submit(service); + await Promise.resolve(); + + expect(fetchRoom).toHaveBeenCalledTimes(1); + finishSave(); + + await expect(first).resolves.toMatchObject({ status: 'saved' }); + await expect(second).resolves.toMatchObject({ status: 'duplicate' }); + expect(fetchRoom).toHaveBeenCalledTimes(2); + expect(room.save).toHaveBeenCalledTimes(1); + }); + + it('serializes simultaneous submissions from different users in one room', async () => { + const room = makeRoom(); + let finishFirstSave; + let notifyFirstSaveStarted; + const firstSaveStarted = new Promise((resolve) => { + notifyFirstSaveStarted = resolve; + }); + room.save + .mockImplementationOnce(() => new Promise((resolve) => { + finishFirstSave = () => resolve(room); + notifyFirstSaveStarted(); + })) + .mockImplementation(async () => room); + const fetchRoom = jest.fn().mockResolvedValue(room); + const service = createResultSubmissionService({ fetchRoom }); + + const first = submit(service); + await firstSaveStarted; + const second = submit(service, { + userId: 43, + submissionId: 'submission-456', + result: { time: 23456, penalties: {} }, + }); + await Promise.resolve(); + + expect(fetchRoom).toHaveBeenCalledTimes(1); + finishFirstSave(); + + await expect(first).resolves.toMatchObject({ status: 'saved' }); + await expect(second).resolves.toMatchObject({ status: 'saved' }); + expect(fetchRoom).toHaveBeenCalledTimes(2); + expect(room.save).toHaveBeenCalledTimes(2); + expect(room.attempts[0].results.has('42')).toBe(true); + expect(room.attempts[0].results.has('43')).toBe(true); + expect(room.waitingFor.get('42')).toBe(false); + expect(room.waitingFor.get('43')).toBe(false); + }); + + it('repairs failed room advancement on a duplicate retry without republishing', async () => { + const room = makeRoom(); + room.doneWithScramble = jest.fn(() => ( + room.attempts.length === 1 && room.waitingFor.get('42') === false + )); + const advanceError = new Error('new attempt save failed'); + const advanceRoom = jest.fn() + .mockRejectedValueOnce(advanceError) + .mockImplementation(async (currentRoom) => { + currentRoom.attempts.push({ + _id: 'attempt-1', + id: 1, + results: new Map(), + }); + currentRoom.waitingFor.set('42', true); + return currentRoom; + }); + const onSaved = jest.fn(); + const service = createResultSubmissionService({ + advanceRoom, + fetchRoom: jest.fn().mockResolvedValue(room), + }); + + await expect(submit(service, { onSaved })).rejects.toMatchObject({ + statusCode: 503, + retryable: true, + cause: advanceError, + }); + expect(room.attempts[0].results.get('42')).toMatchObject({ + submissionId: 'submission-123', + }); + expect(onSaved).toHaveBeenCalledTimes(1); + + await expect(submit(service, { onSaved })).resolves.toMatchObject({ + status: 'duplicate', + }); + expect(advanceRoom).toHaveBeenCalledTimes(2); + expect(room.attempts).toHaveLength(2); + expect(onSaved).toHaveBeenCalledTimes(1); + }); + + it('does not advance twice when duplicate retries arrive concurrently', async () => { + const existingResult = { + time: 12345, + penalties: {}, + submissionId: 'submission-123', + }; + const room = makeRoom({ existingResult }); + room.waitingFor.set('42', false); + room.doneWithScramble = jest.fn(() => room.attempts.length === 1); + let finishAdvance; + let notifyAdvanceStarted; + const advanceStarted = new Promise((resolve) => { + notifyAdvanceStarted = resolve; + }); + const advanceRoom = jest.fn(() => new Promise((resolve) => { + notifyAdvanceStarted(); + finishAdvance = () => { + room.attempts.push({ + _id: 'attempt-1', + id: 1, + results: new Map(), + }); + resolve(room); + }; + })); + const fetchRoom = jest.fn().mockResolvedValue(room); + const service = createResultSubmissionService({ advanceRoom, fetchRoom }); + + const first = submit(service); + await advanceStarted; + const second = submit(service); + await Promise.resolve(); + + expect(fetchRoom).toHaveBeenCalledTimes(1); + finishAdvance(); + + await expect(first).resolves.toMatchObject({ status: 'duplicate' }); + await expect(second).resolves.toMatchObject({ status: 'duplicate' }); + expect(fetchRoom).toHaveBeenCalledTimes(2); + expect(advanceRoom).toHaveBeenCalledTimes(1); + expect(room.attempts).toHaveLength(2); + }); + + it.each([ + [{ inRoom: false }, 403, false], + [{ banned: true }, 403, false], + [{ attempts: 0 }, 400, false], + ])('rejects invalid room state', async (roomOptions, statusCode, retryable) => { + const room = makeRoom(roomOptions); + const service = createResultSubmissionService({ + fetchRoom: jest.fn().mockResolvedValue(room), + }); + + await expect(submit(service)).rejects.toMatchObject({ statusCode, retryable }); + expect(room.save).not.toHaveBeenCalled(); + }); + + it('marks database failures as retryable without exposing their details', async () => { + const databaseError = new Error('private connection details'); + const service = createResultSubmissionService({ + fetchRoom: jest.fn().mockRejectedValue(databaseError), + }); + + let failure; + try { + await submit(service); + } catch (err) { + failure = err; + } + + expect(failure).toBeInstanceOf(ResultSubmissionError); + expect(failure.toResponse()).toEqual({ + statusCode: 503, + message: 'Failed to save result', + retryable: true, + }); + expect(failure.cause).toBe(databaseError); + }); +}); diff --git a/server/socket/lib/roomAvailability.js b/server/socket/lib/roomAvailability.js new file mode 100644 index 0000000..7b09f67 --- /dev/null +++ b/server/socket/lib/roomAvailability.js @@ -0,0 +1,5 @@ +const isRoomTypeEnabled = (type, grandPrixEnabled) => ( + type !== 'grand_prix' || grandPrixEnabled +); + +module.exports = { isRoomTypeEnabled }; diff --git a/server/socket/lib/roomAvailability.test.js b/server/socket/lib/roomAvailability.test.js new file mode 100644 index 0000000..e9cacc9 --- /dev/null +++ b/server/socket/lib/roomAvailability.test.js @@ -0,0 +1,15 @@ +/* eslint-env jest */ + +const { isRoomTypeEnabled } = require('./roomAvailability'); + +describe('room type availability', () => { + it('always exposes normal rooms', () => { + expect(isRoomTypeEnabled('normal', false)).toBe(true); + expect(isRoomTypeEnabled('normal', true)).toBe(true); + }); + + it('exposes Grand Prix rooms only when explicitly enabled', () => { + expect(isRoomTypeEnabled('grand_prix', false)).toBe(false); + expect(isRoomTypeEnabled('grand_prix', true)).toBe(true); + }); +}); diff --git a/server/socket/namespaces/default.js b/server/socket/namespaces/default.js index 5266f81..0c2084a 100644 --- a/server/socket/namespaces/default.js +++ b/server/socket/namespaces/default.js @@ -1,7 +1,7 @@ const logger = require('../../logger'); -const Protocol = require('../../../client/src/lib/protocol'); +const Protocol = require('../../../client/src/lib/protocol.json'); -module.exports = (io, middlewares) => { +module.exports = (io, middlewares, reportHealth) => { const ns = io.of('/'); middlewares.forEach((middleware) => { @@ -24,6 +24,15 @@ module.exports = (io, middlewares) => { updateUsersOnline(); + socket.on(Protocol.HEALTH_CHECK, async (acknowledgment) => { + const report = await reportHealth(); + if (typeof acknowledgment === 'function') { + acknowledgment(report); + } else { + socket.emit(Protocol.HEALTH_STATUS, report); + } + }); + socket.on(Protocol.DISCONNECT, () => { logger.info(`socket ${socket.id} disconnected from default;`); updateUsersOnline(); diff --git a/server/socket/namespaces/default.test.js b/server/socket/namespaces/default.test.js new file mode 100644 index 0000000..cc71e21 --- /dev/null +++ b/server/socket/namespaces/default.test.js @@ -0,0 +1,60 @@ +/* eslint-env jest */ + +const Protocol = require('../../../client/src/lib/protocol.json'); +const initDefault = require('./default'); + +jest.mock('../../logger', () => ({ + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), +})); + +describe('default socket namespace health check', () => { + const setup = (report) => { + const handlers = {}; + const socket = { + id: 'socket-123', + user: null, + on: jest.fn((event, handler) => { + handlers[event] = handler; + }), + emit: jest.fn(), + }; + const namespace = { + adapter: { + allRooms: jest.fn().mockResolvedValue(new Set()), + }, + emit: jest.fn(), + on: jest.fn((event, handler) => { + handlers[event] = handler; + }), + use: jest.fn(), + }; + const io = { of: jest.fn().mockReturnValue(namespace) }; + const reportHealth = jest.fn().mockResolvedValue(report); + + initDefault(io, [], reportHealth); + handlers.connection(socket); + + return { handlers, reportHealth, socket }; + }; + + it('acknowledges a health check with the current report', async () => { + const report = { status: 'ok', service: 'socket' }; + const { handlers, reportHealth } = setup(report); + const acknowledgment = jest.fn(); + await handlers[Protocol.HEALTH_CHECK](acknowledgment); + + expect(reportHealth).toHaveBeenCalledTimes(1); + expect(acknowledgment).toHaveBeenCalledWith(report); + }); + + it('emits health status when no acknowledgment is provided', async () => { + const report = { status: 'ok', service: 'socket' }; + const { handlers, socket } = setup(report); + + await handlers[Protocol.HEALTH_CHECK](); + + expect(socket.emit).toHaveBeenCalledWith(Protocol.HEALTH_STATUS, report); + }); +}); diff --git a/server/socket/namespaces/rooms.js b/server/socket/namespaces/rooms.js index 877f013..e61f614 100644 --- a/server/socket/namespaces/rooms.js +++ b/server/socket/namespaces/rooms.js @@ -1,14 +1,23 @@ 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 config = require('../../runtimeConfig'); 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'); const { canAccessRoom, canDeleteRoom } = require('../lib/roomAuthorization'); +const { isRoomTypeEnabled: checkRoomTypeEnabled } = require('../lib/roomAvailability'); const { removeUserFromRoomSockets } = require('../lib/roomSockets'); +const { createReconnectGrace } = require('../lib/reconnectGrace'); +const { + createResultSubmissionService, + ResultSubmissionError, +} = require('../lib/resultSubmission'); const { createSafeSocketHandler, optionalAcknowledgment, @@ -33,12 +42,14 @@ const roomMask = (room) => ({ // Data for people in room const joinRoomMask = _.partial(_.pick, _, privateRoomKeys); +const isRoomTypeEnabled = (type) => checkRoomTypeEnabled(type, config.grandPrix.enabled); const fetchRoom = (id) => Room.findById({ _id: id }).populate('users').populate('admin').populate('owner'); const getRooms = (userId) => Room.find().populate('users').populate('admin').populate('owner') .then((rooms) => rooms.filter((room) => ( - userId ? !room.banned.get(userId.toString()) : true + isRoomTypeEnabled(room.type) + && (userId ? !room.banned.get(userId.toString()) : true) )).map(roomMask)); const roomTimerObj = {}; @@ -59,45 +70,98 @@ module.exports = (io, middlewares) => { return [...sockets].some((socketId) => socketId !== currentSocketId); }; - async function markNormalRoomsStaleOnStartup() { - const rooms = await Room.find({ type: 'normal' }) + const hasActiveSocketsForUserRoom = async (userId, roomId) => ( + (await socketsForUserRoom(userId, roomId)).size > 0 + ); + + async function listActiveRoomUsers() { + const rooms = await Room.find() .populate('users') .populate('admin') .populate('owner'); - await Promise.all(rooms.map(async (room) => { - room.users.forEach((user) => { - room.inRoom.set(user.id.toString(), false); - room.waitingFor.set(user.id.toString(), false); - }); + return rooms.flatMap((room) => room.usersInRoom.map((user) => ({ + roomId: room._id, + userId: user.id, + leaveReason: 'disconnect', + }))); + } - room.admin = null; - await room.updateStale(true); - })); + async function createAndSendNewScramble(room) { + const updatedRoom = await room.newAttempt(); + logger.debug('Sending new scramble to room', { roomId: room.id }); + ns().in(room.accessCode).emit(Protocol.NEW_ATTEMPT, { + waitingFor: updatedRoom.waitingFor, + attempt: updatedRoom.attempts[updatedRoom.attempts.length - 1], + }); + return updatedRoom; + } - logger.info('Marked normal rooms stale on socket startup', { - rooms: rooms.length, + function sendNewScramble(room) { + return createAndSendNewScramble(room).catch((err) => { + logger.error(err); }); } - const normalRoomsReady = markNormalRoomsStaleOnStartup().catch((err) => { - logger.error(err); + const resultSubmissions = createResultSubmissionService({ + advanceRoom: createAndSendNewScramble, + fetchRoom, }); - function sendNewScramble(room) { - return room.newAttempt().then((r) => { - logger.debug('Sending new scramble to room', { roomId: room.id }); - ns().in(room.accessCode).emit(Protocol.NEW_ATTEMPT, { - waitingFor: r.waitingFor, - attempt: r.attempts[r.attempts.length - 1], - }); + function sendGlobalRoomUpdate(room) { + if (isRoomTypeEnabled(room.type)) { + ns().emit(Protocol.GLOBAL_ROOM_UPDATED, roomMask(room)); + } + } - return r; - }).catch((err) => { - logger.error(err); + async function finalizeUserDeparture({ + roomId, userId, connectionId, leaveReason, + }) { + const room = await fetchRoom(roomId); + const userKey = userId.toString(); + + if (!room || !room.inRoom.get(userKey)) { + return false; + } + + if (leaveReason === 'disconnect' + && await hasActiveSocketsForUserRoom(userId, roomId)) { + return false; + } + + const updatedRoom = await room.dropUser({ id: userId }, (_room) => { + ns().in(room.accessCode).emit(Protocol.UPDATE_ADMIN, _room.admin); }); + + await metrics.endRoomVisit({ + room: updatedRoom, + userId, + connectionId, + leaveReason, + activeUserCount: updatedRoom.usersLength, + }); + + ns().in(updatedRoom.accessCode).emit(Protocol.USER_LEFT, userId); + sendGlobalRoomUpdate(updatedRoom); + + if (updatedRoom.doneWithScramble()) { + logger.debug('everyone done, sending new scramble'); + await sendNewScramble(updatedRoom); + } + + return true; } + const reconnectGrace = createReconnectGrace({ + graceMs: config.socketio.reconnectGraceMs, + hasActiveSockets: hasActiveSocketsForUserRoom, + finalizeDeparture: finalizeUserDeparture, + listActiveUsers: listActiveRoomUsers, + logger, + }); + + reconnectGrace.startReconciliation(); + const interval = 60 * 1000; // 30 seconds function startTimer(room) { @@ -153,16 +217,18 @@ module.exports = (io, middlewares) => { clearInterval(roomTimerObj[room._id]); } - Room.find({ type: 'grand_prix' }) - .then((rooms) => { - rooms.forEach(async (room) => { - if (room.startTime && Date.now() < new Date(room.startTime).getTime()) { - awaitRoomStart(room); - } else { - startTimer(room); - } + if (config.grandPrix.enabled) { + Room.find({ type: 'grand_prix' }) + .then((rooms) => { + rooms.forEach(async (room) => { + if (room.startTime && Date.now() < new Date(room.startTime).getTime()) { + awaitRoomStart(room); + } else { + startTimer(room); + } + }); }); - }); + } async function updateClientsWithUsers() { const sockets = [...await ns().adapter.allRooms([])] @@ -181,8 +247,6 @@ module.exports = (io, middlewares) => { } ns().on('connection', async (socket) => { - await normalRoomsReady; - const on = createSafeSocketHandler(socket, logger, Protocol.ERROR); logger.debug(`socket ${socket.id} connected to rooms; logged in as ${socket.user ? socket.user.name : 'Anonymous'}`); @@ -271,7 +335,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, @@ -282,17 +346,13 @@ module.exports = (io, middlewares) => { return; } - const room = await socket.room.dropUser(socket.user, (_room) => { - ns().in(socket.room.accessCode).emit(Protocol.UPDATE_ADMIN, _room.admin); + reconnectGrace.cancel(socket.roomId, socket.userId); + await reconnectGrace.finalize({ + roomId: socket.roomId, + userId: socket.userId, + connectionId: socket.id, + leaveReason, }); - - broadcast(Protocol.USER_LEFT, socket.user.id); - ns().emit(Protocol.GLOBAL_ROOM_UPDATED, roomMask(room)); - - if (room.doneWithScramble()) { - logger.debug('everyone done, sending new scramble'); - sendNewScramble(room); - } } catch (e) { logger.error(e); } @@ -301,6 +361,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,30 +378,58 @@ 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)); } socket.join(encodeUserRoom(socket.userId, room._id)); + const waitedForCleanup = await reconnectGrace.cancel(room._id, socket.userId); + const activeRoom = waitedForCleanup ? await fetchRoom(room._id) : room; + if (!activeRoom) { + return cb({ + statusCode: 404, + message: 'Room no longer exists', + }); + } - const r = await room.addUser(socket.user, spectating, (_room) => { + const r = await activeRoom.addUser(socket.user, spectating, (_room) => { ns().in(_room.accessCode).emit(Protocol.UPDATE_ADMIN, _room.admin); }); if (!r) { - // Join the socket to the room anyways but don't add them - return cb(null, joinRoomMask(room)); + // The user is already present in persisted room state. + socket.room = activeRoom; + await metrics.beginRoomVisit({ + room: activeRoom, + userId: socket.userId, + connectionId: socket.id, + activeUserCount: activeRoom.usersLength, + }); + return cb(null, joinRoomMask(activeRoom)); } 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)); - broadcast(Protocol.USER_JOIN, socket.user); // tell everyone - ns().emit(Protocol.GLOBAL_ROOM_UPDATED, roomMask(r)); + broadcast(Protocol.USER_JOIN, socket.user); + sendGlobalRoomUpdate(r); - if (room.doneWithScramble()) { + if (r.doneWithScramble()) { logger.debug('everyone done, sending new scramble'); - sendNewScramble(room); + sendNewScramble(r); } } @@ -344,49 +438,66 @@ 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 (!isRoomTypeEnabled(room.type)) { + return rejectJoin('grand_prix_disabled', { + statusCode: 403, + message: 'Grand Prix rooms are disabled', + }, room); + } + 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', }); @@ -403,10 +514,10 @@ module.exports = (io, middlewares) => { }); } - if (options.type === 'grand_prix') { + if (options.type === 'grand_prix' && !config.grandPrix.enabled) { return acknowledgment({ statusCode: 403, - message: 'Not allowed to make a Grand Prix Room', + message: 'Grand Prix rooms are disabled', }); } @@ -428,6 +539,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 +578,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); @@ -519,43 +635,89 @@ module.exports = (io, middlewares) => { } }); - on(Protocol.SUBMIT_RESULT, async ({ id, result }) => { - if (!socket.user || !socket.roomId) { - return; - } - - try { - if (!socket.room.attempts[id]) { - socket.emit(Protocol.ERROR, { - statusCode: 400, + on(Protocol.SUBMIT_RESULT, async (payload = {}, cb) => { + const acknowledgment = optionalAcknowledgment(cb); + const rejectSubmission = (error) => { + if (typeof cb === 'function') { + return acknowledgment(error); + } + if (socket.connected) { + return socket.emit(Protocol.ERROR, { + ...error, event: Protocol.SUBMIT_RESULT, - message: 'Invalid ID for attempt submission', }); - return; - } - - if (socket.room.type === 'grand_prix') { - result.penalties.DNF = result.penalties.DNF - || id < socket.room.attempts.length - 1; } - socket.room.attempts[id].results.set(socket.user.id.toString(), result); - socket.room.waitingFor.set(socket.user.id.toString(), false); + return undefined; + }; - const r = await socket.room.save(); + if (!socket.user) { + return rejectSubmission({ + statusCode: 401, + message: 'Must be logged in to submit a result', + retryable: false, + }); + } + if (!socket.roomId) { + return rejectSubmission({ + statusCode: 409, + message: 'Must rejoin the room before submitting a result', + retryable: true, + }); + } - ns().in(r.accessCode).emit(Protocol.NEW_RESULT, { - id, - result, + const { + id, attemptKey, result, submissionId, + } = payload || {}; + let submission; + try { + submission = await resultSubmissions.submit({ + roomId: socket.roomId, userId: socket.user.id, + attemptId: id, + attemptKey, + result, + submissionId, + onSaved: ({ room, result: savedResult }) => { + socket.room = room; + try { + ns().in(room.accessCode).emit(Protocol.NEW_RESULT, { + id, + result: savedResult, + userId: socket.user.id, + }); + } catch (err) { + logger.error(err); + } + Promise.resolve() + .then(() => metrics.recordRoomResult({ + room, + userId: socket.userId, + })) + .catch((err) => logger.error(err)); + }, }); - - if (r.doneWithScramble()) { - logger.debug('everyone done, sending new scramble'); - sendNewScramble(r); + } catch (err) { + if (err.cause) { + logger.error(err.cause); + } else if (!(err instanceof ResultSubmissionError)) { + logger.error(err); } - } catch (e) { - logger.error(e); + const error = err instanceof ResultSubmissionError + ? err.toResponse() + : { + statusCode: 500, + message: 'Failed to save result', + retryable: true, + }; + return rejectSubmission(error); } + + socket.room = submission.room; + acknowledgment(null, { + submissionId: submission.submissionId, + status: submission.status, + }); + return undefined; }); on(Protocol.SEND_EDIT_RESULT, async (result) => { @@ -600,6 +762,14 @@ module.exports = (io, middlewares) => { if (!checkAdmin()) { return; } + if (!isRoomTypeEnabled(socket.room.type)) { + socket.emit(Protocol.ERROR, { + statusCode: 403, + event: Protocol.REQUEST_SCRAMBLE, + message: 'Grand Prix rooms are disabled', + }); + return; + } sendNewScramble(socket.room); }); @@ -608,6 +778,14 @@ module.exports = (io, middlewares) => { if (!checkAdmin()) { return; } + if (!isRoomTypeEnabled(socket.room.type)) { + socket.emit(Protocol.ERROR, { + statusCode: 403, + event: Protocol.CHANGE_EVENT, + message: 'Grand Prix rooms are disabled', + }); + return; + } socket.room.changeEvent(event).then((r) => { ns().in(r.accessCode).emit(Protocol.UPDATE_ROOM, joinRoomMask(socket.room)); @@ -619,8 +797,13 @@ module.exports = (io, middlewares) => { return; } - if (options.type === 'grand_prix') { + if (options.type === 'grand_prix' && !config.grandPrix.enabled) { logger.error(`${socket.id} attempted to edit room to grand_prix`); + socket.emit(Protocol.ERROR, { + statusCode: 403, + event: Protocol.EDIT_ROOM, + message: 'Grand Prix rooms are disabled', + }); return; } @@ -629,7 +812,10 @@ module.exports = (io, middlewares) => { ns().in(room.accessCode).emit(Protocol.UPDATE_ROOM, joinRoomMask(room)); Room.find().then((rooms) => { - ns().emit(Protocol.UPDATE_ROOMS, rooms.map(roomMask)); + ns().emit( + Protocol.UPDATE_ROOMS, + rooms.filter((candidate) => isRoomTypeEnabled(candidate.type)).map(roomMask), + ); }); } catch (e) { (logger.error(e)); @@ -640,6 +826,14 @@ module.exports = (io, middlewares) => { if (!checkAdmin()) { return; } + if (!isRoomTypeEnabled(socket.room.type)) { + socket.emit(Protocol.ERROR, { + statusCode: 403, + event: Protocol.START_ROOM, + message: 'Grand Prix rooms are disabled', + }); + return; + } const room = await socket.room.start(); try { @@ -668,6 +862,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); @@ -676,7 +877,7 @@ module.exports = (io, middlewares) => { } ns().in(socket.room.accessCode).emit(Protocol.USER_LEFT, userId); - ns().emit(Protocol.GLOBAL_ROOM_UPDATED, roomMask(room)); + sendGlobalRoomUpdate(room); if (room.doneWithScramble()) { logger.debug('everyone done, sending new scramble'); @@ -695,6 +896,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); @@ -703,7 +911,7 @@ module.exports = (io, middlewares) => { } ns().in(room.accessCode).emit(Protocol.UPDATE_ROOM, joinRoomMask(room)); - ns().emit(Protocol.GLOBAL_ROOM_UPDATED, roomMask(room)); + sendGlobalRoomUpdate(room); if (room.doneWithScramble()) { logger.debug('everyone done, sending new scramble'); @@ -727,7 +935,7 @@ module.exports = (io, middlewares) => { } ns().in(room.accessCode).emit(Protocol.UPDATE_ROOM, joinRoomMask(room)); - ns().emit(Protocol.GLOBAL_ROOM_UPDATED, roomMask(room)); + sendGlobalRoomUpdate(room); } catch (e) { logger.error(e); } @@ -765,7 +973,19 @@ 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(); + reconnectGrace.schedule({ + roomId: socket.roomId, + userId: socket.userId, + connectionId: socket.id, + leaveReason: 'disconnect', + }); + } else if (socket.room) { + await metrics.endRoomVisit({ + room: socket.room, + connectionId: socket.id, + leaveReason: 'disconnect', + activeUserCount: socket.room.usersLength, + }); } updateClientsWithUsers(); @@ -777,8 +997,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"