From 1cefa89aee146956c228a1616851e7bcb76fe8b4 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Thu, 9 Jul 2026 16:07:51 -0700 Subject: [PATCH 1/8] Add PostgreSQL dual writes and room metrics Mirror new application data and pseudonymous metrics into PostgreSQL while MongoDB remains the source of truth. Add versioned migrations, Compose services, retention controls, and focused coverage for the migration path. --- .env.example | 14 + README.md | 50 ++- client/src/store/index.js | 3 +- compose.yml | 26 ++ docker-compose.yml | 18 ++ server/auth/index.js | 19 +- server/config/default.json | 7 + server/config/dev.json | 9 +- server/index.js | 3 + server/metrics.js | 227 +++++++++++++ server/metrics.test.js | 192 +++++++++++ server/models/index.js | 3 + server/models/metricEvent.js | 112 +++++++ server/models/room.js | 6 + server/models/user.js | 9 + server/package.json | 2 + server/postgres/dualWrite.js | 301 ++++++++++++++++++ server/postgres/dualWrite.test.js | 132 ++++++++ server/postgres/index.js | 177 ++++++++++ server/postgres/migrate.js | 9 + .../migrations/001_initial_dual_write.sql | 105 ++++++ .../002_stable_attempt_identity.sql | 23 ++ .../migrations/003_source_compatibility.sql | 11 + server/runtimeConfig.js | 33 +- server/socket/index.js | 2 + server/socket/namespaces/rooms.js | 113 ++++++- yarn.lock | 85 ++++- 27 files changed, 1664 insertions(+), 27 deletions(-) create mode 100644 server/metrics.js create mode 100644 server/metrics.test.js create mode 100644 server/models/metricEvent.js create mode 100644 server/postgres/dualWrite.js create mode 100644 server/postgres/dualWrite.test.js create mode 100644 server/postgres/index.js create mode 100644 server/postgres/migrate.js create mode 100644 server/postgres/migrations/001_initial_dual_write.sql create mode 100644 server/postgres/migrations/002_stable_attempt_identity.sql create mode 100644 server/postgres/migrations/003_source_compatibility.sql diff --git a/.env.example b/.env.example index 318dcc9..32c5e21 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,17 @@ 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= +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 +31,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/README.md b/README.md index 5819086..1d83db1 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,30 @@ # 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. PostgreSQL migrations run automatically when +either backend process starts, or manually with: + +```bash +yarn workspace letscube-server postgres:migrate +``` **Server** @@ -33,3 +42,36 @@ 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. + +Set `POSTGRES_ENABLED=false` to disable mirroring. Production should set +`PGHOST`, `PGDATABASE`, `PGUSER`, and `POSTGRES_PASSWORD`, or provide a +`DATABASE_URL`. External TLS connections can set `PGSSL=true` and provide a CA +with `PGSSL_CA`; certificate verification is enabled by default. PostgreSQL +failures are logged but do not fail the corresponding MongoDB-backed +application operation during this migration phase. diff --git a/client/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/compose.yml b/compose.yml index 72ceca4..cdf2c69 100644 --- a/compose.yml +++ b/compose.yml @@ -6,6 +6,16 @@ x-app-environment: &app-environment SOCKETIO_PORT: ${SOCKETIO_PORT:-9000} MONGO_URL: ${MONGO_URL:-mongodb://mongo:27017/letscube} REDIS_URL: ${REDIS_URL:-redis://redis:6379} + POSTGRES_ENABLED: ${POSTGRES_ENABLED:-true} + DATABASE_URL: ${DATABASE_URL:-} + 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} @@ -64,6 +74,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 +102,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/server/auth/index.js b/server/auth/index.js index e2613d9..91219c9 100644 --- a/server/auth/index.js +++ b/server/auth/index.js @@ -2,6 +2,7 @@ const express = require('express'); const CustomStrategy = require('passport-custom').Strategy; const { URLSearchParams } = require('url'); const { User } = require('../models'); +const metrics = require('../metrics'); const checkStatus = async (res) => { if (res.ok) { // res.status >= 200 && res.status < 300 @@ -20,10 +21,11 @@ module.exports = (app, passport) => { const { code, redirectUri } = req.body; if (!code) { - done(new Error({ - status: 400, - message: 'Invalid code passed', - })); + const err = new Error('Invalid code passed'); + Object.defineProperty(err, 'authFailureReason', { + value: 'missing_code', + }); + done(err); return; } @@ -118,19 +120,24 @@ module.exports = (app, passport) => { router.post('/code', (req, res, next) => { - passport.authenticate('custom', (err, user) => { + passport.authenticate('custom', async (err, user) => { if (err) { + await metrics.recordAuthFailure( + err.authFailureReason || 'provider_or_persistence_error', + ); return res.status(500).json(err); } if (!user) { + await metrics.recordAuthFailure('no_user'); return res.status(401).json({ message: 'Authentication failed', }); } - req.login(user, (loginErr) => { + req.login(user, async (loginErr) => { if (loginErr) { + await metrics.recordAuthFailure('session_login_error'); return res.json(loginErr); } diff --git a/server/config/default.json b/server/config/default.json index e4a27b9..8c3bbd8 100644 --- a/server/config/default.json +++ b/server/config/default.json @@ -6,6 +6,13 @@ "port": 9000 }, "mongodb": "mongodb://127.0.0.1/letscube", + "postgres": { + "host": "127.0.0.1", + "port": 5432, + "database": "letscube", + "user": "letscube", + "password": "letscube" + }, "wcaSource": "https://staging.worldcubeassociation.org", "auth": { "secret": "", diff --git a/server/config/dev.json b/server/config/dev.json index 18ea264..115e325 100644 --- a/server/config/dev.json +++ b/server/config/dev.json @@ -7,6 +7,13 @@ "port": 9000 }, "mongodb": "mongodb://127.0.0.1/letscube", + "postgres": { + "host": "127.0.0.1", + "port": 55433, + "database": "letscube", + "user": "letscube", + "password": "letscube" + }, "wcaSource": "https://staging.worldcubeassociation.org", "auth": { "secret": "dev-secret", @@ -17,4 +24,4 @@ "cors": { "origin": ["localhost", "192.168.1.18"] } -} \ No newline at end of file +} diff --git a/server/index.js b/server/index.js index 4a85e6e..3293ac0 100644 --- a/server/index.js +++ b/server/index.js @@ -7,6 +7,7 @@ const passport = require('passport'); const config = require('./runtimeConfig'); const { connect } = require('./database'); +const { initializePostgres, startPostgresMaintenance } = require('./postgres'); const session = require('./middlewares/session'); const logger = require('./logger'); const auth = require('./auth'); @@ -25,6 +26,8 @@ const init = async () => { app.use(express.urlencoded({ extended: true })); // for parsing application/x-www-form-urlencoded const mongoose = await connect(); + await initializePostgres(); + startPostgresMaintenance(); /* Logging */ diff --git a/server/metrics.js b/server/metrics.js new file mode 100644 index 0000000..f1fb7ce --- /dev/null +++ b/server/metrics.js @@ -0,0 +1,227 @@ +const crypto = require('crypto'); +const { v4: uuidv4 } = require('uuid'); + +const config = require('./runtimeConfig'); +const logger = require('./logger'); +const { METRIC_EVENTS, MetricEvent } = require('./models'); +const { mirrorMetricEvent } = require('./postgres/dualWrite'); + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +const countRoomSolves = (room) => room.attempts.reduce((total, attempt) => { + const resultCount = attempt.results instanceof Map + ? attempt.results.size + : Object.keys(attempt.results || {}).length; + return total + resultCount; +}, 0); + +const createMetricRecorder = ({ + model = MetricEvent, + metricsConfig = config.metrics, + metricLogger = logger, + metricMirror = mirrorMetricEvent, + now = () => new Date(), +} = {}) => { + const enabled = !!metricsConfig.enabled; + const retentionMs = metricsConfig.retentionDays * MS_PER_DAY; + + const pseudonymize = (kind, value) => { + if (value === undefined || value === null || !metricsConfig.hashSecret) { + return undefined; + } + + return crypto.createHmac('sha256', metricsConfig.hashSecret) + .update(`${kind}:${value}`) + .digest('hex'); + }; + + const roomProperties = (room) => { + if (!room) { + return {}; + } + + return { + roomId: pseudonymize('room', room._id || room.id), + roomType: room.type, + cubeEvent: room.event, + privateRoom: !!room.password, + }; + }; + + const actorProperties = ({ userId, connectionId }) => ({ + actorId: pseudonymize(userId ? 'user' : 'connection', userId || connectionId), + actorType: userId ? 'authenticated' : 'anonymous', + }); + + const write = async (event, { ignoreDuplicate = false } = {}) => { + if (!enabled) { + return null; + } + + const occurredAt = now(); + const persistedEvent = { + ...event, + eventId: uuidv4(), + occurredAt, + expiresAt: new Date(occurredAt.getTime() + retentionMs), + }; + let created = null; + try { + created = await model.create(persistedEvent); + } catch (err) { + if (!ignoreDuplicate || err.code !== 11000) { + metricLogger.error(err); + } + if (ignoreDuplicate && err.code === 11000) { + return null; + } + } + + Promise.resolve() + .then(() => metricMirror(persistedEvent)) + .catch((err) => metricLogger.error(err)); + return created; + }; + + const beginRoomVisit = async ({ + room, userId, connectionId, activeUserCount, replaceActive = false, + }) => { + if (!enabled) { + return null; + } + + const event = { + event: METRIC_EVENTS.ROOM_JOINED, + ...actorProperties({ userId, connectionId }), + ...roomProperties(room), + activeUserCount, + active: true, + }; + + if (replaceActive && event.actorId && event.roomId) { + try { + await model.updateMany({ + event: METRIC_EVENTS.ROOM_JOINED, + actorId: event.actorId, + roomId: event.roomId, + active: true, + }, { + $set: { + active: false, + closedAt: now(), + }, + }); + } catch (err) { + metricLogger.error(err); + } + } + + // An authenticated user may join through multiple tabs. The partial unique + // index turns those sockets into one logical room visit. + const created = await write(event, { ignoreDuplicate: true }); + if (created || !event.actorId || !event.roomId) { + return created; + } + + try { + return await model.findOne({ + event: METRIC_EVENTS.ROOM_JOINED, + actorId: event.actorId, + roomId: event.roomId, + active: true, + }); + } catch (err) { + metricLogger.error(err); + return null; + } + }; + + const endRoomVisit = async ({ + room, userId, connectionId, leaveReason, activeUserCount, + }) => { + if (!enabled) { + return null; + } + + const actor = actorProperties({ userId, connectionId }); + const roomData = roomProperties(room); + const closedAt = now(); + let durationMs; + let visitFound = false; + + if (actor.actorId && roomData.roomId) { + try { + const visit = await model.findOneAndUpdate({ + event: METRIC_EVENTS.ROOM_JOINED, + actorId: actor.actorId, + roomId: roomData.roomId, + active: true, + }, { + $set: { + active: false, + closedAt, + }, + }, { + sort: { occurredAt: -1 }, + }); + + if (visit) { + visitFound = true; + durationMs = Math.max(0, closedAt.getTime() - visit.occurredAt.getTime()); + } + } catch (err) { + metricLogger.error(err); + } + } + + // A kick or ban is followed by the affected socket disconnecting. Only the + // first path that closes the active visit should emit a leave event. + if (actor.actorId && roomData.roomId && !visitFound) { + return null; + } + + return write({ + event: METRIC_EVENTS.ROOM_LEFT, + ...actor, + ...roomData, + leaveReason, + activeUserCount, + durationMs, + }); + }; + + return { + recordAuthFailure: (failureReason) => write({ + event: METRIC_EVENTS.AUTH_FAILED, + failureReason, + }), + recordRoomCreated: ({ room, userId }) => write({ + event: METRIC_EVENTS.ROOM_CREATED, + ...actorProperties({ userId }), + ...roomProperties(room), + }), + recordRoomJoinFailure: ({ + room, userId, connectionId, failureReason, + }) => write({ + event: METRIC_EVENTS.ROOM_JOIN_FAILED, + ...actorProperties({ userId, connectionId }), + ...roomProperties(room), + failureReason, + }), + recordRoomResult: ({ room, userId }) => write({ + event: METRIC_EVENTS.ROOM_RESULT_SUBMITTED, + ...actorProperties({ userId }), + ...roomProperties(room), + roomSolveCount: countRoomSolves(room), + }), + beginRoomVisit, + endRoomVisit, + pseudonymize, + }; +}; + +module.exports = { + ...createMetricRecorder(), + countRoomSolves, + createMetricRecorder, +}; diff --git a/server/metrics.test.js b/server/metrics.test.js new file mode 100644 index 0000000..c380e1b --- /dev/null +++ b/server/metrics.test.js @@ -0,0 +1,192 @@ +/** @jest-environment node */ +/* eslint-env jest */ + +const { METRIC_EVENTS } = require('./models/metricEvent'); +const { countRoomSolves, createMetricRecorder } = require('./metrics'); + +const room = { + _id: 'room-123', + type: 'normal', + event: '333', + password: 'hashed-password', + attempts: [ + { results: new Map([['1', {}], ['2', {}]]) }, + { results: new Map([['1', {}]]) }, + ], +}; + +const createRecorder = (overrides = {}) => { + const model = { + create: jest.fn(async (event) => event), + findOne: jest.fn(), + findOneAndUpdate: jest.fn(), + updateMany: jest.fn(), + }; + const metricLogger = { error: jest.fn() }; + const metricMirror = jest.fn(); + const now = jest.fn(() => new Date('2026-07-09T12:00:00.000Z')); + const recorder = createMetricRecorder({ + model, + metricLogger, + metricMirror, + now, + metricsConfig: { + enabled: true, + hashSecret: 'metrics-test-secret', + retentionDays: 90, + }, + ...overrides, + }); + + return { + model, metricLogger, metricMirror, now, recorder, + }; +}; + +describe('metrics recorder', () => { + it('pseudonymizes users and rooms with separated identifiers', () => { + const { recorder } = createRecorder(); + + const userId = recorder.pseudonymize('user', 123); + const roomId = recorder.pseudonymize('room', 123); + + expect(userId).toHaveLength(64); + expect(userId).not.toBe('123'); + expect(userId).not.toBe(roomId); + expect(recorder.pseudonymize('user', 123)).toBe(userId); + }); + + it('records one active room visit without raw identifiers or room names', async () => { + const { model, recorder } = createRecorder(); + + await recorder.beginRoomVisit({ + room, + userId: 123, + connectionId: 'socket-secret', + activeUserCount: 4, + }); + + expect(model.create).toHaveBeenCalledWith(expect.objectContaining({ + event: METRIC_EVENTS.ROOM_JOINED, + actorType: 'authenticated', + activeUserCount: 4, + roomType: 'normal', + cubeEvent: '333', + privateRoom: true, + active: true, + occurredAt: new Date('2026-07-09T12:00:00.000Z'), + expiresAt: new Date('2026-10-07T12:00:00.000Z'), + })); + + const event = model.create.mock.calls[0][0]; + expect(event.actorId).not.toContain('123'); + expect(event.roomId).not.toContain('room-123'); + expect(event).not.toHaveProperty('connectionId'); + expect(event).not.toHaveProperty('name'); + expect(event).not.toHaveProperty('password'); + }); + + it('closes a stale visit when the room state says this is a new join', async () => { + const { model, recorder } = createRecorder(); + + await recorder.beginRoomVisit({ + room, + userId: 123, + activeUserCount: 4, + replaceActive: true, + }); + + expect(model.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + event: METRIC_EVENTS.ROOM_JOINED, + active: true, + }), + { $set: expect.objectContaining({ active: false }) }, + ); + expect(model.create).toHaveBeenCalledTimes(1); + }); + + it('records a leave with the duration of the open visit', async () => { + const joinedAt = new Date('2026-07-09T11:55:00.000Z'); + const { model, recorder } = createRecorder(); + model.findOneAndUpdate.mockResolvedValue({ occurredAt: joinedAt }); + + await recorder.endRoomVisit({ + room, + userId: 123, + leaveReason: 'explicit', + activeUserCount: 3, + }); + + expect(model.findOneAndUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + event: METRIC_EVENTS.ROOM_JOINED, + active: true, + }), + { $set: expect.objectContaining({ active: false }) }, + { sort: { occurredAt: -1 } }, + ); + expect(model.create).toHaveBeenCalledWith(expect.objectContaining({ + event: METRIC_EVENTS.ROOM_LEFT, + leaveReason: 'explicit', + durationMs: 5 * 60 * 1000, + activeUserCount: 3, + })); + }); + + it('does not duplicate a leave after a visit has already closed', async () => { + const { model, recorder } = createRecorder(); + model.findOneAndUpdate.mockResolvedValue(null); + + await expect(recorder.endRoomVisit({ + room, + userId: 123, + leaveReason: 'disconnect', + activeUserCount: 3, + })).resolves.toBeNull(); + + expect(model.create).not.toHaveBeenCalled(); + }); + + it('records join and authentication failure categories only', async () => { + const { model, recorder } = createRecorder(); + + await recorder.recordRoomJoinFailure({ + room, + userId: 123, + failureReason: 'invalid_password', + }); + await recorder.recordAuthFailure('missing_code'); + + expect(model.create).toHaveBeenNthCalledWith(1, expect.objectContaining({ + event: METRIC_EVENTS.ROOM_JOIN_FAILED, + failureReason: 'invalid_password', + })); + expect(model.create).toHaveBeenNthCalledWith(2, expect.objectContaining({ + event: METRIC_EVENTS.AUTH_FAILED, + failureReason: 'missing_code', + })); + }); + + it('counts unique result slots without storing solve times', async () => { + const { model, recorder } = createRecorder(); + + expect(countRoomSolves(room)).toBe(3); + await recorder.recordRoomResult({ room, userId: 123 }); + + expect(model.create).toHaveBeenCalledWith(expect.objectContaining({ + event: METRIC_EVENTS.ROOM_RESULT_SUBMITTED, + roomSolveCount: 3, + })); + expect(model.create.mock.calls[0][0]).not.toHaveProperty('attempts'); + }); + + it('does not disrupt application behavior when metrics storage fails', async () => { + const { model, metricLogger, recorder } = createRecorder(); + const error = new Error('metrics database unavailable'); + model.create.mockRejectedValue(error); + + await expect(recorder.recordAuthFailure('no_user')).resolves.toBeNull(); + expect(metricLogger.error).toHaveBeenCalledWith(error); + }); +}); diff --git a/server/models/index.js b/server/models/index.js index 86f1922..8e5ba12 100644 --- a/server/models/index.js +++ b/server/models/index.js @@ -1,9 +1,12 @@ const mongoose = require('mongoose'); const { Attempt, Room } = require('./room'); +const { METRIC_EVENTS, MetricEvent } = require('./metricEvent'); const User = require('./user'); module.exports = { Room: mongoose.model('Room', Room, 'rooms'), User: mongoose.model('User', User, 'users'), Attempt: mongoose.model('Attempt', Attempt, 'attempts'), + MetricEvent: mongoose.model('MetricEvent', MetricEvent, 'metric_events'), + METRIC_EVENTS, }; diff --git a/server/models/metricEvent.js b/server/models/metricEvent.js new file mode 100644 index 0000000..56b0a5b --- /dev/null +++ b/server/models/metricEvent.js @@ -0,0 +1,112 @@ +const mongoose = require('mongoose'); +const { v4: uuidv4 } = require('uuid'); + +const METRIC_EVENTS = Object.freeze({ + AUTH_FAILED: 'auth_failed', + ROOM_CREATED: 'room_created', + ROOM_JOINED: 'room_joined', + ROOM_JOIN_FAILED: 'room_join_failed', + ROOM_LEFT: 'room_left', + ROOM_RESULT_SUBMITTED: 'room_result_submitted', +}); + +const MetricEvent = new mongoose.Schema({ + eventId: { + type: String, + default: uuidv4, + required: true, + }, + event: { + type: String, + enum: Object.values(METRIC_EVENTS), + required: true, + index: true, + }, + actorId: { + type: String, + index: true, + }, + actorType: { + type: String, + enum: ['authenticated', 'anonymous'], + }, + roomId: { + type: String, + index: true, + }, + roomType: { + type: String, + enum: ['normal', 'grand_prix'], + }, + cubeEvent: String, + privateRoom: Boolean, + failureReason: String, + leaveReason: { + type: String, + enum: ['disconnect', 'explicit', 'kick', 'ban'], + }, + activeUserCount: { + type: Number, + min: 0, + }, + roomSolveCount: { + type: Number, + min: 0, + }, + durationMs: { + type: Number, + min: 0, + }, + active: { + type: Boolean, + default: false, + }, + closedAt: Date, + occurredAt: { + type: Date, + default: Date.now, + required: true, + index: true, + }, + expiresAt: { + type: Date, + required: true, + }, +}, { + versionKey: false, +}); + +MetricEvent.index({ + expiresAt: 1, +}, { + expireAfterSeconds: 0, +}); + +MetricEvent.index({ + eventId: 1, +}, { + unique: true, + partialFilterExpression: { + eventId: { $type: 'string' }, + }, +}); + +// Prevent multiple browser tabs from opening overlapping visits for one user. +MetricEvent.index({ + actorId: 1, + roomId: 1, + event: 1, +}, { + unique: true, + partialFilterExpression: { + event: METRIC_EVENTS.ROOM_JOINED, + active: true, + actorId: { $type: 'string' }, + roomId: { $type: 'string' }, + }, +}); + +module.exports = { + METRIC_EVENTS, + MetricEvent, +}; diff --git a/server/models/room.js b/server/models/room.js index 322d22a..0366f9b 100644 --- a/server/models/room.js +++ b/server/models/room.js @@ -3,6 +3,7 @@ const { Scrambow } = require('scrambow'); const bcrypt = require('bcrypt'); const { v4: uuidv4 } = require('uuid'); const { Events } = require('../../client/src/lib/events'); +const { mirrorRoom } = require('../postgres/dualWrite'); const PASSWORD_SALT_ROUNDS = 10; const STALE_ROOM_LIFETIME_MS = 10 * 60 * 1000; @@ -326,5 +327,10 @@ Room.methods.updateAdminIfNeeded = function (cb) { } }; +Room.post('save', (room) => { + // Mirror complete snapshots so retries and later backfills remain idempotent. + mirrorRoom(room); +}); + module.exports.Attempt = Attempt; module.exports.Room = Room; 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..d97ce66 100644 --- a/server/package.json +++ b/server/package.json @@ -20,6 +20,7 @@ "passport-local": "^1.0.0", "passport-oauth2": "^1.8.0", "passport-wca": "^1.0.3", + "pg": "8.22.0", "rest-api-errors": "^1.2.1", "scrambow": "^1.8.1", "socket.io": "^4.8.3", @@ -31,6 +32,7 @@ "start:socket": "nodemon socket/", "lint": "eslint ./", "lint:fix": "eslint --fix ./", + "postgres:migrate": "node postgres/migrate.js", "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..784929b --- /dev/null +++ b/server/postgres/dualWrite.js @@ -0,0 +1,301 @@ +/* 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 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 + `, [ + 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 mirrorRoom = (room) => withTransaction(async (client) => { + 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 relatedUsers = [room.owner, room.admin, ...users] + .filter((user, index, all) => user && all.indexOf(user) === index); + const knownUserIds = new Set(); + + for (const user of relatedUsers) { + 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 + `, [ + 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 users) { + 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 + `, [ + 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, + ]); + } + + for (const [attemptIndex, attempt] of (room.attempts || []).entries()) { + const ordinal = Number.isInteger(attempt.id) ? attempt.id : attemptIndex; + const attemptMongoId = attempt._id + ? attempt._id.toString() + : `${room._id}:${room.event}:${attempt.createdAt || updatedAt}:${ordinal}`; + const attemptId = stableId('attempt', attemptMongoId); + const attemptUpdatedAt = sourceDate(attempt.updatedAt, 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 + `, [ + attemptId, + attemptMongoId, + roomId, + ordinal, + room.event, + JSON.stringify(attempt.scrambles || []), + attempt.createdAt || null, + attemptUpdatedAt, + ]); + + for (const [userKey, result] of resultEntries(attempt.results)) { + const wcaUserId = numericUserId(userKey); + if (!wcaUserId || !knownUserIds.has(wcaUserId) || !result) { + continue; + } + + const resultUpdatedAt = sourceDate(result.updatedAt, attemptUpdatedAt); + await client.query(` + INSERT INTO app.solves ( + id, attempt_id, room_id, user_id, time_ms, penalties, + source_created_at, source_updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (attempt_id, user_id) DO UPDATE SET + time_ms = EXCLUDED.time_ms, + penalties = EXCLUDED.penalties, + source_updated_at = EXCLUDED.source_updated_at, + ingested_at = now() + WHERE app.solves.source_updated_at <= EXCLUDED.source_updated_at + `, [ + stableId('solve', `${attemptMongoId}:${wcaUserId}`), + attemptId, + roomId, + stableId('user', wcaUserId), + result.time, + result.penalties || {}, + result.createdAt || null, + resultUpdatedAt, + ]); + } + } + + return roomId; +}); + +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, + mirrorUser, + numericUserId, + stableId, +}; diff --git a/server/postgres/dualWrite.test.js b/server/postgres/dualWrite.test.js new file mode 100644 index 0000000..41f1990 --- /dev/null +++ b/server/postgres/dualWrite.test.js @@ -0,0 +1,132 @@ +/** @jest-environment node */ +/* eslint-env jest */ + +jest.mock('./index', () => ({ + query: jest.fn(), + withTransaction: jest.fn(), +})); + +const postgres = require('./index'); +const { + markRoomDeleted, + mirrorMetricEvent, + mirrorRoom, + 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: { plusTwo: false, DNF: true }, + 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[1]).toContain(-1); + expect(solveCall[1]).not.toContain('room-access-code'); + }); + + 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..9ae5c7b --- /dev/null +++ b/server/postgres/index.js @@ -0,0 +1,177 @@ +/* eslint-disable no-await-in-loop, no-continue, no-restricted-syntax */ +const fs = require('fs'); +const path = require('path'); +const { Pool } = require('pg'); + +const config = require('../runtimeConfig'); +const logger = require('../logger'); + +const RETRY_DELAY_MS = 30 * 1000; +const MIGRATION_LOCK_NAME = 'letscube-postgres-migrations'; + +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 migrationFiles = () => fs.readdirSync(path.join(__dirname, 'migrations')) + .filter((file) => file.endsWith('.sql')) + .sort(); + +const runMigrations = async (client) => { + await client.query('SELECT pg_advisory_lock(hashtext($1))', [MIGRATION_LOCK_NAME]); + try { + await client.query('CREATE SCHEMA IF NOT EXISTS app'); + await client.query(` + CREATE TABLE IF NOT EXISTS app.schema_migrations ( + filename text PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT now() + ) + `); + + const applied = new Set((await client.query( + 'SELECT filename FROM app.schema_migrations', + )).rows.map(({ filename }) => filename)); + + for (const filename of migrationFiles()) { + if (applied.has(filename)) { + continue; + } + + const sql = fs.readFileSync(path.join(__dirname, 'migrations', filename), 'utf8'); + await client.query('BEGIN'); + try { + await client.query(sql); + await client.query( + 'INSERT INTO app.schema_migrations (filename) VALUES ($1)', + [filename], + ); + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } + } + } finally { + await client.query('SELECT pg_advisory_unlock(hashtext($1))', [MIGRATION_LOCK_NAME]); + } +}; + +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 () => { + const client = await pool.connect(); + try { + await runMigrations(client); + initialized = true; + logger.info('[POSTGRES] Connected and migrations are current'); + return true; + } finally { + client.release(); + } + })().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/postgres/migrate.js b/server/postgres/migrate.js new file mode 100644 index 0000000..0de66c2 --- /dev/null +++ b/server/postgres/migrate.js @@ -0,0 +1,9 @@ +const { initializePostgres, pool } = require('./index'); + +initializePostgres() + .then((initialized) => { + if (!initialized) { + process.exitCode = 1; + } + }) + .finally(() => pool.end()); diff --git a/server/postgres/migrations/001_initial_dual_write.sql b/server/postgres/migrations/001_initial_dual_write.sql new file mode 100644 index 0000000..6852df1 --- /dev/null +++ b/server/postgres/migrations/001_initial_dual_write.sql @@ -0,0 +1,105 @@ +CREATE SCHEMA IF NOT EXISTS analytics; + +CREATE TABLE IF NOT EXISTS 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 UNIQUE INDEX IF NOT EXISTS users_username_lower_unique + ON app.users (lower(username)) + WHERE username IS NOT NULL AND username <> ''; + +CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS app.attempts ( + id uuid PRIMARY KEY, + room_id uuid NOT NULL REFERENCES app.rooms(id) ON DELETE CASCADE, + ordinal integer NOT NULL CHECK (ordinal >= 0), + scrambles jsonb NOT NULL, + source_created_at timestamptz, + source_updated_at timestamptz NOT NULL, + ingested_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (room_id, ordinal) +); + +CREATE TABLE IF NOT EXISTS 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 >= 0), + 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 IF NOT EXISTS solves_user_created_idx + ON app.solves (user_id, source_created_at DESC); + +CREATE INDEX IF NOT EXISTS attempts_room_ordinal_idx + ON app.attempts (room_id, ordinal); + +CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS analytics_events_name_time_idx + ON analytics.events (event_name, occurred_at DESC); + +CREATE INDEX IF NOT EXISTS analytics_events_room_time_idx + ON analytics.events (room_id, occurred_at DESC) + WHERE room_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS analytics_events_expiry_idx + ON analytics.events (expires_at); diff --git a/server/postgres/migrations/002_stable_attempt_identity.sql b/server/postgres/migrations/002_stable_attempt_identity.sql new file mode 100644 index 0000000..cd2454d --- /dev/null +++ b/server/postgres/migrations/002_stable_attempt_identity.sql @@ -0,0 +1,23 @@ +ALTER TABLE app.attempts + ADD COLUMN IF NOT EXISTS mongo_id text, + ADD COLUMN IF NOT EXISTS cube_event text; + +UPDATE app.attempts +SET mongo_id = id::text +WHERE mongo_id IS NULL; + +UPDATE app.attempts +SET cube_event = rooms.cube_event +FROM app.rooms +WHERE app.attempts.room_id = rooms.id + AND app.attempts.cube_event IS NULL; + +ALTER TABLE app.attempts + ALTER COLUMN mongo_id SET NOT NULL, + ALTER COLUMN cube_event SET NOT NULL; + +ALTER TABLE app.attempts + DROP CONSTRAINT IF EXISTS attempts_room_id_ordinal_key; + +CREATE UNIQUE INDEX IF NOT EXISTS attempts_mongo_id_unique + ON app.attempts (mongo_id); diff --git a/server/postgres/migrations/003_source_compatibility.sql b/server/postgres/migrations/003_source_compatibility.sql new file mode 100644 index 0000000..d758546 --- /dev/null +++ b/server/postgres/migrations/003_source_compatibility.sql @@ -0,0 +1,11 @@ +ALTER TABLE app.solves + DROP CONSTRAINT IF EXISTS solves_time_ms_check; + +ALTER TABLE app.solves + ADD CONSTRAINT solves_time_ms_check CHECK (time_ms >= -1); + +DROP INDEX IF EXISTS app.users_username_lower_unique; + +CREATE INDEX IF NOT EXISTS users_username_lower_idx + ON app.users (lower(username)) + WHERE username IS NOT NULL AND username <> ''; diff --git a/server/runtimeConfig.js b/server/runtimeConfig.js index 1762c33..382b628 100644 --- a/server/runtimeConfig.js +++ b/server/runtimeConfig.js @@ -13,6 +13,15 @@ const parseOrigins = (value, fallback) => { return value.split(',').map((origin) => origin.trim()).filter(Boolean); }; +const parsePositiveInteger = (value, fallback) => { + const parsed = Number.parseInt(value, 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +}; + +const authSecret = process.env.AUTH_SECRET + || process.env.SESSION_SECRET + || baseConfig.auth.secret; + const redisUrl = process.env.REDIS_URL || (baseConfig.redis && baseConfig.redis.url) || 'redis://localhost:6379'; @@ -55,7 +64,7 @@ module.exports = { wcaSource: process.env.WCA_SOURCE || process.env.REACT_APP_WCA_ORIGIN || baseConfig.wcaSource, auth: { ...baseConfig.auth, - secret: process.env.AUTH_SECRET || process.env.SESSION_SECRET || baseConfig.auth.secret, + secret: authSecret, callbackURL: process.env.AUTH_CALLBACK_URL || baseConfig.auth.callbackURL || baseConfig.auth.callbackUrl, @@ -71,4 +80,26 @@ module.exports = { ...baseConfig.cors, origin: parseOrigins(process.env.CORS_ORIGINS, baseConfig.cors.origin), }, + metrics: { + enabled: process.env.METRICS_ENABLED !== 'false', + hashSecret: process.env.METRICS_HASH_SECRET || authSecret, + retentionDays: parsePositiveInteger(process.env.METRICS_RETENTION_DAYS, 90), + }, + postgres: { + enabled: process.env.POSTGRES_ENABLED !== 'false', + connectionString: process.env.DATABASE_URL, + host: process.env.PGHOST || (baseConfig.postgres && baseConfig.postgres.host) || '127.0.0.1', + port: parsePort(process.env.PGPORT, (baseConfig.postgres && baseConfig.postgres.port) || 5432), + database: process.env.PGDATABASE + || (baseConfig.postgres && baseConfig.postgres.database) + || 'letscube', + user: process.env.PGUSER || (baseConfig.postgres && baseConfig.postgres.user) || 'letscube', + password: process.env.PGPASSWORD + || process.env.POSTGRES_PASSWORD + || (baseConfig.postgres && baseConfig.postgres.password) + || 'letscube', + ssl: process.env.PGSSL === 'true', + sslRejectUnauthorized: process.env.PGSSL_REJECT_UNAUTHORIZED !== 'false', + sslCa: process.env.PGSSL_CA && process.env.PGSSL_CA.replace(/\\n/g, '\n'), + }, }; diff --git a/server/socket/index.js b/server/socket/index.js index 18a5d10..39f7f30 100644 --- a/server/socket/index.js +++ b/server/socket/index.js @@ -7,6 +7,7 @@ const expressSocketSession = require('express-socket.io-session'); const config = require('../runtimeConfig'); const session = require('../middlewares/session'); const { connect } = require('../database'); +const { initializePostgres } = require('../postgres'); const logger = require('../logger'); const loggerMiddleware = require('./middlewares/logger'); const authenticateMiddleware = require('./middlewares/authenticate'); @@ -49,6 +50,7 @@ const init = async () => { io.on('error', logSocketError('server')); const mongoose = await connect(); + await initializePostgres(); const pubClient = new Redis(config.redis.url || { host: config.redis.host, diff --git a/server/socket/namespaces/rooms.js b/server/socket/namespaces/rooms.js index 877f013..d45f713 100644 --- a/server/socket/namespaces/rooms.js +++ b/server/socket/namespaces/rooms.js @@ -4,6 +4,8 @@ const Protocol = require('../../../client/src/lib/protocol'); const ChatMessage = require('../lib/ChatMessage'); const { parseCommand } = require('../lib/commands'); const logger = require('../../logger'); +const metrics = require('../../metrics'); +const { markRoomDeleted } = require('../../postgres/dualWrite'); const { Room, User } = require('../../models'); const { encodeUserRoom } = require('../utils'); const roomMap = require('../lib/roomMap'); @@ -271,7 +273,7 @@ module.exports = (io, middlewares) => { } // Only deals with removing authenticated users from a room - async function leaveRoom() { + async function leaveRoom(leaveReason) { try { const hasOtherSockets = await hasOtherSocketsForUserRoom( socket.userId, @@ -286,6 +288,14 @@ module.exports = (io, middlewares) => { ns().in(socket.room.accessCode).emit(Protocol.UPDATE_ADMIN, _room.admin); }); + await metrics.endRoomVisit({ + room, + userId: socket.userId, + connectionId: socket.id, + leaveReason, + activeUserCount: room.usersLength, + }); + broadcast(Protocol.USER_LEFT, socket.user.id); ns().emit(Protocol.GLOBAL_ROOM_UPDATED, roomMask(room)); @@ -301,6 +311,12 @@ module.exports = (io, middlewares) => { async function joinRoom(room, cb, spectating) { if (socket.roomId) { logger.debug('Socket is already in room', { roomId: socket.room._id }); + await metrics.recordRoomJoinFailure({ + room: socket.room, + userId: socket.userId, + connectionId: socket.id, + failureReason: 'already_in_room', + }); return cb({ statusCode: 400, message: 'Socket is already in room', @@ -312,6 +328,12 @@ module.exports = (io, middlewares) => { if (!socket.user) { logger.debug('Socket is not authenticated but joining anyways', { roomId: room._id, userId: socket.userId }); + socket.room = room; + await metrics.beginRoomVisit({ + room, + connectionId: socket.id, + activeUserCount: room.usersLength, + }); return cb(null, joinRoomMask(room)); } @@ -323,10 +345,24 @@ module.exports = (io, middlewares) => { if (!r) { // Join the socket to the room anyways but don't add them + socket.room = room; + await metrics.beginRoomVisit({ + room, + userId: socket.userId, + connectionId: socket.id, + activeUserCount: room.usersLength, + }); return cb(null, joinRoomMask(room)); } socket.room = r; + await metrics.beginRoomVisit({ + room: r, + userId: socket.userId, + connectionId: socket.id, + activeUserCount: r.usersLength, + replaceActive: true, + }); socket.emit(Protocol.JOIN, joinRoomMask(r)); cb(null, joinRoomMask(r)); @@ -344,49 +380,59 @@ module.exports = (io, middlewares) => { const acknowledgment = optionalAcknowledgment(cb); const { id, spectating, password } = payload || {}; + const rejectJoin = async (failureReason, error, room) => { + await metrics.recordRoomJoinFailure({ + room, + userId: socket.userId, + connectionId: socket.id, + failureReason, + }); + return acknowledgment(error, room ? roomMask(room) : undefined); + }; + try { const room = await fetchRoom(id); if (!room) { - return acknowledgment({ + return rejectJoin('not_found', { statusCode: 404, message: `Could not find room with id ${id}`, }); } if (room.private && !password) { - return acknowledgment({ + return rejectJoin('password_required', { statusCode: 403, message: 'Room requires password to join', - }, roomMask(room)); + }, room); } if (room.private && !(await room.authenticate(password))) { - return acknowledgment({ + return rejectJoin('invalid_password', { statusCode: 403, message: 'Invalid password', - }, roomMask(room)); + }, room); } if (socket.userId && room.banned.get(socket.userId.toString())) { logger.debug(`Banned user ${socket.user.id} is trying to join room ${room._id}`); - return acknowledgment({ + return rejectJoin('banned', { statusCode: 401, message: 'Banned', banned: true, - }, roomMask(room)); + }, room); } if (room.requireRevealedIdentity && (!socket.user || !socket.user.showWCAID)) { - return acknowledgment({ + return rejectJoin('identity_required', { statusCode: 403, message: 'Must be showing WCA Identity to join room.', - }, roomMask(room)); + }, room); } return await joinRoom(room, acknowledgment, spectating); } catch (e) { logger.error(e); - return acknowledgment({ + return rejectJoin('internal_error', { statusCode: 500, message: 'Failed to join room', }); @@ -428,6 +474,10 @@ module.exports = (io, middlewares) => { newRoom.owner = socket.user; const room = await newRoom.save(); + await metrics.recordRoomCreated({ + room, + userId: socket.userId, + }); ns().emit(Protocol.ROOM_CREATED, roomMask(room)); return joinRoom(room, (err, r) => { if (err) { @@ -463,6 +513,7 @@ module.exports = (io, middlewares) => { const res = await Room.deleteOne({ _id: room._id }); if (res.deletedCount > 0) { + await markRoomDeleted(room._id); socket.room = undefined; acknowledgment(null); ns().emit(Protocol.ROOM_DELETED, id); @@ -538,11 +589,19 @@ module.exports = (io, middlewares) => { result.penalties.DNF = result.penalties.DNF || id < socket.room.attempts.length - 1; } + const previousResult = socket.room.attempts[id].results.get(socket.user.id.toString()); socket.room.attempts[id].results.set(socket.user.id.toString(), result); socket.room.waitingFor.set(socket.user.id.toString(), false); const r = await socket.room.save(); + if (!previousResult) { + await metrics.recordRoomResult({ + room: r, + userId: socket.userId, + }); + } + ns().in(r.accessCode).emit(Protocol.NEW_RESULT, { id, result, @@ -668,6 +727,13 @@ module.exports = (io, middlewares) => { try { const room = await socket.room.dropUser({ id: userId }); + await metrics.endRoomVisit({ + room, + userId, + leaveReason: 'kick', + activeUserCount: room.usersLength, + }); + ns().in(encodeUserRoom(userId, socket.room._id)).emit(Protocol.KICKED); removeUserFromRoomSockets(ns(), userId, socket.room); @@ -695,6 +761,13 @@ module.exports = (io, middlewares) => { try { const room = await socket.room.banUser(userId); + await metrics.endRoomVisit({ + room, + userId, + leaveReason: 'ban', + activeUserCount: room.usersLength, + }); + ns().in(encodeUserRoom(userId, socket.room._id)).emit(Protocol.BANNED); removeUserFromRoomSockets(ns(), userId, socket.room); @@ -765,7 +838,14 @@ module.exports = (io, middlewares) => { logger.info(`socket ${socket.id} disconnected; Left room: ${socket.room ? socket.room.name : 'Null'}`, { roomId: socket.roomId }); if (socket.user && socket.room) { - await leaveRoom(); + await leaveRoom('disconnect'); + } else if (socket.room) { + await metrics.endRoomVisit({ + room: socket.room, + connectionId: socket.id, + leaveReason: 'disconnect', + activeUserCount: socket.room.usersLength, + }); } updateClientsWithUsers(); @@ -777,8 +857,15 @@ module.exports = (io, middlewares) => { on(Protocol.LEAVE_ROOM, async () => { if (socket.room) { if (socket.user) { - await leaveRoom(); + await leaveRoom('explicit'); socket.leave(encodeUserRoom(socket.userId, socket.room._id)); + } else { + await metrics.endRoomVisit({ + room: socket.room, + connectionId: socket.id, + leaveReason: 'explicit', + activeUserCount: socket.room.usersLength, + }); } socket.leave(socket.room.accessCode); diff --git a/yarn.lock b/yarn.lock index adaf1c0..3abd935 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3864,7 +3864,7 @@ cheerio-select@^2.1.0: domhandler "^5.0.3" domutils "^3.0.1" -cheerio@1.0.0-rc.12, cheerio@^1.0.0-rc.3: +cheerio@^1.0.0-rc.3: version "1.0.0-rc.12" resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.12.tgz#788bf7466506b1c6bf5fae51d24a2c4d62e47683" integrity sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q== @@ -9152,6 +9152,62 @@ performance-now@^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" @@ -9225,6 +9281,28 @@ 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" + prelude-ls@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" @@ -10677,6 +10755,11 @@ 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" From b77e23eba6349f7883b67ec3266e03c4ed1bab9c Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Thu, 9 Jul 2026 16:14:36 -0700 Subject: [PATCH 2/8] Rename initial PostgreSQL schema migration Describe the migration by its schema responsibility rather than the temporary dual-write rollout strategy. Keep the initial username index compatible with the final schema when migrations are replayed. --- .../{001_initial_dual_write.sql => 001_initial_schema.sql} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename server/postgres/migrations/{001_initial_dual_write.sql => 001_initial_schema.sql} (98%) diff --git a/server/postgres/migrations/001_initial_dual_write.sql b/server/postgres/migrations/001_initial_schema.sql similarity index 98% rename from server/postgres/migrations/001_initial_dual_write.sql rename to server/postgres/migrations/001_initial_schema.sql index 6852df1..f9e3c37 100644 --- a/server/postgres/migrations/001_initial_dual_write.sql +++ b/server/postgres/migrations/001_initial_schema.sql @@ -14,7 +14,7 @@ CREATE TABLE IF NOT EXISTS app.users ( ingested_at timestamptz NOT NULL DEFAULT now() ); -CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique +CREATE INDEX IF NOT EXISTS users_username_lower_idx ON app.users (lower(username)) WHERE username IS NOT NULL AND username <> ''; From 1308629a01af55127f5ebeed0ec4232a9f970c39 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Thu, 9 Jul 2026 16:32:20 -0700 Subject: [PATCH 3/8] Manage PostgreSQL schema with Prisma Replace the custom migration registry with a canonical Prisma schema and Prisma Migrate history. Run migrate deploy as a one-shot production service and enforce schema validation and drift checks in CI. --- .env.example | 2 + .github/workflows/ci.yml | 29 ++ Dockerfile | 5 +- README.md | 21 +- compose.dev.yml | 4 + compose.prod.yml | 3 + compose.yml | 11 + package.json | 1 + server/package.json | 7 +- server/postgres/index.js | 60 +-- server/postgres/migrate.js | 9 - .../002_stable_attempt_identity.sql | 23 - .../migrations/003_source_compatibility.sql | 11 - server/prisma.config.mjs | 28 + .../migration.sql} | 36 +- server/prisma/migrations/migration_lock.toml | 1 + server/prisma/schema.prisma | 128 +++++ yarn.lock | 486 +++++++++++++++++- 18 files changed, 736 insertions(+), 129 deletions(-) delete mode 100644 server/postgres/migrate.js delete mode 100644 server/postgres/migrations/002_stable_attempt_identity.sql delete mode 100644 server/postgres/migrations/003_source_compatibility.sql create mode 100644 server/prisma.config.mjs rename server/{postgres/migrations/001_initial_schema.sql => prisma/migrations/20260709230000_initial_schema/migration.sql} (80%) create mode 100644 server/prisma/migrations/migration_lock.toml create mode 100644 server/prisma/schema.prisma diff --git a/.env.example b/.env.example index 32c5e21..2ef5254 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,8 @@ 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 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 1d83db1..ba300a9 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,20 @@ docker compose -f docker-compose.yml up -d 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. PostgreSQL migrations run automatically when -either backend process starts, or manually with: +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** The server is split across 2 processes: @@ -71,7 +78,9 @@ so retries and future backfills are idempotent. Set `POSTGRES_ENABLED=false` to disable mirroring. Production should set `PGHOST`, `PGDATABASE`, `PGUSER`, and `POSTGRES_PASSWORD`, or provide a -`DATABASE_URL`. 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. +`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/compose.dev.yml b/compose.dev.yml index 6ced287..a1689a4 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -1,4 +1,8 @@ services: + migrate: + profiles: + - migrations + client: image: node:22-bookworm working_dir: /app diff --git a/compose.prod.yml b/compose.prod.yml index 687a6b3..a76a6bb 100644 --- a/compose.prod.yml +++ b/compose.prod.yml @@ -7,6 +7,9 @@ x-app-build: &app-build REACT_APP_WCA_CLIENT_ID: ${REACT_APP_WCA_CLIENT_ID:-} services: + migrate: + build: *app-build + api: build: *app-build restart: unless-stopped diff --git a/compose.yml b/compose.yml index cdf2c69..574303d 100644 --- a/compose.yml +++ b/compose.yml @@ -8,6 +8,7 @@ x-app-environment: &app-environment 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} @@ -25,6 +26,16 @@ x-app-environment: &app-environment CORS_ORIGINS: ${CORS_ORIGINS:-https://letscube.net,https://www.letscube.net} services: + migrate: + image: letscube-app:${APP_IMAGE_TAG:-local} + command: ["./node_modules/.bin/prisma", "migrate", "deploy", "--config", "server/prisma.config.mjs"] + environment: + <<: *app-environment + depends_on: + postgres: + condition: service_healthy + restart: "no" + api: image: letscube-app:${APP_IMAGE_TAG:-local} command: ["node", "server/index.js"] diff --git a/package.json b/package.json index f94b7f5..5790db9 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "server" ], "resolutions": { + "@hono/node-server": "1.19.13", "cheerio": "1.0.0-rc.12" }, "scripts": { diff --git a/server/package.json b/server/package.json index d97ce66..e06ba46 100644 --- a/server/package.json +++ b/server/package.json @@ -21,6 +21,7 @@ "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", @@ -32,7 +33,11 @@ "start:socket": "nodemon socket/", "lint": "eslint ./", "lint:fix": "eslint --fix ./", - "postgres:migrate": "node postgres/migrate.js", + "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/index.js b/server/postgres/index.js index 9ae5c7b..47242f4 100644 --- a/server/postgres/index.js +++ b/server/postgres/index.js @@ -1,13 +1,9 @@ -/* eslint-disable no-await-in-loop, no-continue, no-restricted-syntax */ -const fs = require('fs'); -const path = require('path'); const { Pool } = require('pg'); const config = require('../runtimeConfig'); const logger = require('../logger'); const RETRY_DELAY_MS = 30 * 1000; -const MIGRATION_LOCK_NAME = 'letscube-postgres-migrations'; const poolConfig = config.postgres.connectionString ? { connectionString: config.postgres.connectionString } @@ -41,49 +37,6 @@ let initialized = false; let initializationPromise; let lastFailureAt = 0; -const migrationFiles = () => fs.readdirSync(path.join(__dirname, 'migrations')) - .filter((file) => file.endsWith('.sql')) - .sort(); - -const runMigrations = async (client) => { - await client.query('SELECT pg_advisory_lock(hashtext($1))', [MIGRATION_LOCK_NAME]); - try { - await client.query('CREATE SCHEMA IF NOT EXISTS app'); - await client.query(` - CREATE TABLE IF NOT EXISTS app.schema_migrations ( - filename text PRIMARY KEY, - applied_at timestamptz NOT NULL DEFAULT now() - ) - `); - - const applied = new Set((await client.query( - 'SELECT filename FROM app.schema_migrations', - )).rows.map(({ filename }) => filename)); - - for (const filename of migrationFiles()) { - if (applied.has(filename)) { - continue; - } - - const sql = fs.readFileSync(path.join(__dirname, 'migrations', filename), 'utf8'); - await client.query('BEGIN'); - try { - await client.query(sql); - await client.query( - 'INSERT INTO app.schema_migrations (filename) VALUES ($1)', - [filename], - ); - await client.query('COMMIT'); - } catch (err) { - await client.query('ROLLBACK'); - throw err; - } - } - } finally { - await client.query('SELECT pg_advisory_unlock(hashtext($1))', [MIGRATION_LOCK_NAME]); - } -}; - const initializePostgres = async () => { if (!config.postgres.enabled) { return false; @@ -99,15 +52,10 @@ const initializePostgres = async () => { } initializationPromise = (async () => { - const client = await pool.connect(); - try { - await runMigrations(client); - initialized = true; - logger.info('[POSTGRES] Connected and migrations are current'); - return true; - } finally { - client.release(); - } + await pool.query('SELECT 1'); + initialized = true; + logger.info('[POSTGRES] Connected'); + return true; })().catch((err) => { lastFailureAt = Date.now(); logger.error(err); diff --git a/server/postgres/migrate.js b/server/postgres/migrate.js deleted file mode 100644 index 0de66c2..0000000 --- a/server/postgres/migrate.js +++ /dev/null @@ -1,9 +0,0 @@ -const { initializePostgres, pool } = require('./index'); - -initializePostgres() - .then((initialized) => { - if (!initialized) { - process.exitCode = 1; - } - }) - .finally(() => pool.end()); diff --git a/server/postgres/migrations/002_stable_attempt_identity.sql b/server/postgres/migrations/002_stable_attempt_identity.sql deleted file mode 100644 index cd2454d..0000000 --- a/server/postgres/migrations/002_stable_attempt_identity.sql +++ /dev/null @@ -1,23 +0,0 @@ -ALTER TABLE app.attempts - ADD COLUMN IF NOT EXISTS mongo_id text, - ADD COLUMN IF NOT EXISTS cube_event text; - -UPDATE app.attempts -SET mongo_id = id::text -WHERE mongo_id IS NULL; - -UPDATE app.attempts -SET cube_event = rooms.cube_event -FROM app.rooms -WHERE app.attempts.room_id = rooms.id - AND app.attempts.cube_event IS NULL; - -ALTER TABLE app.attempts - ALTER COLUMN mongo_id SET NOT NULL, - ALTER COLUMN cube_event SET NOT NULL; - -ALTER TABLE app.attempts - DROP CONSTRAINT IF EXISTS attempts_room_id_ordinal_key; - -CREATE UNIQUE INDEX IF NOT EXISTS attempts_mongo_id_unique - ON app.attempts (mongo_id); diff --git a/server/postgres/migrations/003_source_compatibility.sql b/server/postgres/migrations/003_source_compatibility.sql deleted file mode 100644 index d758546..0000000 --- a/server/postgres/migrations/003_source_compatibility.sql +++ /dev/null @@ -1,11 +0,0 @@ -ALTER TABLE app.solves - DROP CONSTRAINT IF EXISTS solves_time_ms_check; - -ALTER TABLE app.solves - ADD CONSTRAINT solves_time_ms_check CHECK (time_ms >= -1); - -DROP INDEX IF EXISTS app.users_username_lower_unique; - -CREATE INDEX IF NOT EXISTS users_username_lower_idx - ON app.users (lower(username)) - WHERE username IS NOT NULL AND username <> ''; 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/postgres/migrations/001_initial_schema.sql b/server/prisma/migrations/20260709230000_initial_schema/migration.sql similarity index 80% rename from server/postgres/migrations/001_initial_schema.sql rename to server/prisma/migrations/20260709230000_initial_schema/migration.sql index f9e3c37..fd0f015 100644 --- a/server/postgres/migrations/001_initial_schema.sql +++ b/server/prisma/migrations/20260709230000_initial_schema/migration.sql @@ -1,6 +1,7 @@ CREATE SCHEMA IF NOT EXISTS analytics; +CREATE SCHEMA IF NOT EXISTS app; -CREATE TABLE IF NOT EXISTS app.users ( +CREATE TABLE app.users ( id uuid PRIMARY KEY, wca_user_id bigint NOT NULL UNIQUE, email text, @@ -14,11 +15,11 @@ CREATE TABLE IF NOT EXISTS app.users ( ingested_at timestamptz NOT NULL DEFAULT now() ); -CREATE INDEX IF NOT EXISTS users_username_lower_idx +CREATE INDEX users_username_lower_idx ON app.users (lower(username)) WHERE username IS NOT NULL AND username <> ''; -CREATE TABLE IF NOT EXISTS app.rooms ( +CREATE TABLE app.rooms ( id uuid PRIMARY KEY, mongo_id text NOT NULL UNIQUE, name text NOT NULL, @@ -40,7 +41,7 @@ CREATE TABLE IF NOT EXISTS app.rooms ( ingested_at timestamptz NOT NULL DEFAULT now() ); -CREATE TABLE IF NOT EXISTS app.room_participants ( +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, @@ -53,23 +54,27 @@ CREATE TABLE IF NOT EXISTS app.room_participants ( PRIMARY KEY (room_id, user_id) ); -CREATE TABLE IF NOT EXISTS app.attempts ( +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(), - UNIQUE (room_id, ordinal) + ingested_at timestamptz NOT NULL DEFAULT now() ); -CREATE TABLE IF NOT EXISTS app.solves ( +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 >= 0), + 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, @@ -77,13 +82,10 @@ CREATE TABLE IF NOT EXISTS app.solves ( UNIQUE (attempt_id, user_id) ); -CREATE INDEX IF NOT EXISTS solves_user_created_idx +CREATE INDEX solves_user_created_idx ON app.solves (user_id, source_created_at DESC); -CREATE INDEX IF NOT EXISTS attempts_room_ordinal_idx - ON app.attempts (room_id, ordinal); - -CREATE TABLE IF NOT EXISTS analytics.events ( +CREATE TABLE analytics.events ( id uuid PRIMARY KEY, event_name text NOT NULL, occurred_at timestamptz NOT NULL, @@ -94,12 +96,12 @@ CREATE TABLE IF NOT EXISTS analytics.events ( ingested_at timestamptz NOT NULL DEFAULT now() ); -CREATE INDEX IF NOT EXISTS analytics_events_name_time_idx +CREATE INDEX analytics_events_name_time_idx ON analytics.events (event_name, occurred_at DESC); -CREATE INDEX IF NOT EXISTS analytics_events_room_time_idx +CREATE INDEX analytics_events_room_time_idx ON analytics.events (room_id, occurred_at DESC) WHERE room_id IS NOT NULL; -CREATE INDEX IF NOT EXISTS analytics_events_expiry_idx +CREATE INDEX analytics_events_expiry_idx ON analytics.events (expires_at); 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..ee184da --- /dev/null +++ b/server/prisma/schema.prisma @@ -0,0 +1,128 @@ +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") + penalties 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) + 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)], 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/yarn.lock b/yarn.lock index 3abd935..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" @@ -3864,7 +4086,7 @@ cheerio-select@^2.1.0: domhandler "^5.0.3" domutils "^3.0.1" -cheerio@^1.0.0-rc.3: +cheerio@1.0.0-rc.12, cheerio@^1.0.0-rc.3: version "1.0.0-rc.12" resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.12.tgz#788bf7466506b1c6bf5fae51d24a2c4d62e47683" integrity sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q== @@ -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,6 +9527,11 @@ 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" @@ -9245,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" @@ -9303,6 +9697,11 @@ postgres-interval@^1.1.0: 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" @@ -9369,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" @@ -9429,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" @@ -9481,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" @@ -9539,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" @@ -9776,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" @@ -9935,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" @@ -10111,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" @@ -10263,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== @@ -10370,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" @@ -10765,6 +11218,11 @@ sprintf-js@~1.0.2: 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" @@ -10822,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" @@ -11759,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" @@ -12334,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" From 63a7e261c0024d009975a4fd034f89a0d8266421 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Thu, 9 Jul 2026 17:01:07 -0700 Subject: [PATCH 4/8] Optimize PostgreSQL solve ingestion Normalize solve penalties and add stable cursor ordering. Mirror only dirty room participants, attempts, and results to avoid replaying room history on every save. --- README.md | 9 +- server/models/room.js | 75 +++- server/models/room.test.js | 75 +++- server/postgres/dualWrite.js | 384 +++++++++++++++--- server/postgres/dualWrite.test.js | 62 ++- .../migration.sql | 20 + server/prisma/schema.prisma | 28 +- 7 files changed, 568 insertions(+), 85 deletions(-) create mode 100644 server/prisma/migrations/20260709234500_normalize_solve_penalties/migration.sql diff --git a/README.md b/README.md index ba300a9..363d1e4 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,14 @@ 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. +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 diff --git a/server/models/room.js b/server/models/room.js index 0366f9b..944d28c 100644 --- a/server/models/room.js +++ b/server/models/room.js @@ -3,7 +3,7 @@ const { Scrambow } = require('scrambow'); const bcrypt = require('bcrypt'); const { v4: uuidv4 } = require('uuid'); const { Events } = require('../../client/src/lib/events'); -const { mirrorRoom } = require('../postgres/dualWrite'); +const { mirrorRoomChanges } = require('../postgres/dualWrite'); const PASSWORD_SALT_ROUNDS = 10; const STALE_ROOM_LIFETIME_MS = 10 * 60 * 1000; @@ -327,10 +327,77 @@ Room.methods.updateAdminIfNeeded = function (cb) { } }; -Room.post('save', (room) => { - // Mirror complete snapshots so retries and later backfills remain idempotent. - mirrorRoom(room); +const collectPostgresChanges = (room) => { + const changesByAttempt = new Map(); + const participantUserIds = new Set(); + const ensureAttempt = (attemptIndex) => { + if (!changesByAttempt.has(attemptIndex)) { + changesByAttempt.set(attemptIndex, { + attemptIndex, + resultUserIds: new Set(), + resultsMapModified: false, + syncAllResults: false, + }); + } + return changesByAttempt.get(attemptIndex); + }; + + room.modifiedPaths({ includeChildren: true }).forEach((path) => { + const participantMatch = path.match( + /^(?:competing|waitingFor|banned|inRoom|registered)\.([^.]+)/, + ); + if (participantMatch) { + participantUserIds.add(participantMatch[1]); + } + + const resultMatch = path.match(/^attempts\.(\d+)\.results(?:\.([^.]+))?/); + if (resultMatch) { + const change = ensureAttempt(Number(resultMatch[1])); + if (resultMatch[2]) { + change.resultUserIds.add(resultMatch[2]); + } else { + change.resultsMapModified = true; + } + return; + } + + const attemptMatch = path.match(/^attempts\.(\d+)\.(?:id|scrambles)(?:\.|$)/); + if (attemptMatch) { + ensureAttempt(Number(attemptMatch[1])); + } + }); + + (room.attempts || []).forEach((attempt, attemptIndex) => { + if (attempt.isNew) { + ensureAttempt(attemptIndex).syncAllResults = true; + } + }); + + const attempts = [...changesByAttempt.values()].map((change) => ({ + attemptIndex: change.attemptIndex, + resultUserIds: [...change.resultUserIds], + syncAllResults: change.syncAllResults + || (change.resultsMapModified && change.resultUserIds.size === 0), + })); + + return { + attempts, + participantUserIds: [...participantUserIds], + replaceAttempts: !room.isNew && room.isModified('event'), + syncAllParticipants: room.isNew, + syncRoomOwners: room.isNew || room.isModified('owner') || room.isModified('admin'), + }; +}; + +Room.pre('save', function capturePostgresChanges() { + this.$locals.postgresChanges = collectPostgresChanges(this); +}); + +Room.post('save', async (room) => { + // PostgreSQL failures are logged and swallowed by the migration-phase writer. + await mirrorRoomChanges(room, room.$locals.postgresChanges); }); module.exports.Attempt = Attempt; +module.exports.collectPostgresChanges = collectPostgresChanges; module.exports.Room = Room; diff --git a/server/models/room.test.js b/server/models/room.test.js index cd844ca..a28039a 100644 --- a/server/models/room.test.js +++ b/server/models/room.test.js @@ -2,7 +2,10 @@ /* eslint-env jest */ const bcrypt = require('bcrypt'); -const { Room } = require('./room'); +const mongoose = require('mongoose'); +const { collectPostgresChanges, Room } = require('./room'); + +const RoomModel = mongoose.model('RoomPostgresChangesTest', Room); describe('room security helpers', () => { it('authenticates passwords asynchronously', async () => { @@ -48,4 +51,74 @@ describe('room security helpers', () => { expect(room.expireAt.getTime()).toBeGreaterThanOrEqual(before + 10 * 60 * 1000); expect(room.expireAt.getTime()).toBeLessThanOrEqual(Date.now() + 10 * 60 * 1000); }); + + it('collects only the changed result for incremental PostgreSQL writes', () => { + const room = RoomModel.hydrate({ + _id: '507f1f77bcf86cd799439011', + name: 'Practice room', + event: '333', + waitingFor: { 1234: true }, + attempts: [{ + _id: '507f1f77bcf86cd799439012', + id: 0, + scrambles: ['R U R\''], + results: { + 1234: { + _id: '507f1f77bcf86cd799439013', + time: 12000, + penalties: {}, + }, + }, + }], + }); + room.attempts[0].results.set('1234', { + time: 13000, + penalties: { AUF: true }, + }); + room.waitingFor.set('1234', false); + + expect(collectPostgresChanges(room)).toEqual({ + attempts: [{ + attemptIndex: 0, + resultUserIds: ['1234'], + syncAllResults: false, + }], + participantUserIds: ['1234'], + replaceAttempts: false, + syncAllParticipants: false, + syncRoomOwners: false, + }); + }); + + it('replaces PostgreSQL attempts when the room event resets them', () => { + const room = RoomModel.hydrate({ + _id: '507f1f77bcf86cd799439011', + name: 'Practice room', + event: '333', + attempts: [{ + _id: '507f1f77bcf86cd799439012', + id: 0, + scrambles: ['R U R\''], + results: {}, + }], + }); + room.event = '222'; + room.attempts = [{ + id: 0, + scrambles: ['R U2 R\''], + results: {}, + }]; + + expect(collectPostgresChanges(room)).toEqual({ + attempts: [{ + attemptIndex: 0, + resultUserIds: [], + syncAllResults: true, + }], + participantUserIds: [], + replaceAttempts: true, + syncAllParticipants: false, + syncRoomOwners: false, + }); + }); }); diff --git a/server/postgres/dualWrite.js b/server/postgres/dualWrite.js index 784929b..7fea99b 100644 --- a/server/postgres/dualWrite.js +++ b/server/postgres/dualWrite.js @@ -40,6 +40,16 @@ const resultEntries = (results) => { 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) { @@ -62,7 +72,25 @@ const upsertUser = async (client, user, fallbackUpdatedAt = new Date()) => { avatar = EXCLUDED.avatar, source_updated_at = EXCLUDED.source_updated_at, ingested_at = now() - WHERE app.users.source_updated_at <= EXCLUDED.source_updated_at + 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, @@ -87,7 +115,7 @@ const upsertUser = async (client, user, fallbackUpdatedAt = new Date()) => { const mirrorUser = (user) => withTransaction((client) => upsertUser(client, user)); -const mirrorRoom = (room) => withTransaction(async (client) => { +const upsertRoomState = async (client, room, options = {}) => { if (!room || !room._id) { return null; } @@ -95,11 +123,32 @@ const mirrorRoom = (room) => withTransaction(async (client) => { const roomId = stableId('room', room._id.toString()); const updatedAt = sourceDate(room.updatedAt); const users = (room.users || []).filter((user) => numericUserId(user)); - const relatedUsers = [room.owner, room.admin, ...users] - .filter((user, index, all) => user && all.indexOf(user) === index); + 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) { + for (const user of relatedUsers.values()) { const mirroredId = await upsertUser(client, user, updatedAt); if (mirroredId) { knownUserIds.add(numericUserId(user)); @@ -140,7 +189,41 @@ const mirrorRoom = (room) => withTransaction(async (client) => { deleted_at = NULL, source_updated_at = EXCLUDED.source_updated_at, ingested_at = now() - WHERE app.rooms.source_updated_at <= EXCLUDED.source_updated_at + 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(), @@ -163,7 +246,7 @@ const mirrorRoom = (room) => withTransaction(async (client) => { adminKnown, ]); - for (const user of users) { + for (const user of participantUsers) { const wcaUserId = numericUserId(user); if (!knownUserIds.has(wcaUserId)) { continue; @@ -182,7 +265,23 @@ const mirrorRoom = (room) => withTransaction(async (client) => { registered = EXCLUDED.registered, source_updated_at = EXCLUDED.source_updated_at, ingested_at = now() - WHERE app.room_participants.source_updated_at <= EXCLUDED.source_updated_at + 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), @@ -195,69 +294,225 @@ const mirrorRoom = (room) => withTransaction(async (client) => { ]); } - for (const [attemptIndex, attempt] of (room.attempts || []).entries()) { - const ordinal = Number.isInteger(attempt.id) ? attempt.id : attemptIndex; - const attemptMongoId = attempt._id - ? attempt._id.toString() - : `${room._id}:${room.event}:${attempt.createdAt || updatedAt}:${ordinal}`; - const attemptId = stableId('attempt', attemptMongoId); - const attemptUpdatedAt = sourceDate(attempt.updatedAt, updatedAt); + return { + knownUserIds, + roomId, + 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 - `, [ - attemptId, - attemptMongoId, - roomId, - ordinal, - room.event, - JSON.stringify(attempt.scrambles || []), - attempt.createdAt || null, - attemptUpdatedAt, - ]); +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); - for (const [userKey, result] of resultEntries(attempt.results)) { - const wcaUserId = numericUserId(userKey); - if (!wcaUserId || !knownUserIds.has(wcaUserId) || !result) { - continue; - } + 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); - const resultUpdatedAt = sourceDate(result.updatedAt, attemptUpdatedAt); + 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(` - INSERT INTO app.solves ( - id, attempt_id, room_id, user_id, time_ms, penalties, - source_created_at, source_updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - ON CONFLICT (attempt_id, user_id) DO UPDATE SET - time_ms = EXCLUDED.time_ms, - penalties = EXCLUDED.penalties, - source_updated_at = EXCLUDED.source_updated_at, - ingested_at = now() - WHERE app.solves.source_updated_at <= EXCLUDED.source_updated_at - `, [ - stableId('solve', `${attemptMongoId}:${wcaUserId}`), - attemptId, - roomId, - stableId('user', wcaUserId), - result.time, - result.penalties || {}, - result.createdAt || null, - resultUpdatedAt, - ]); + 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), +); - return roomId; +// 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(` @@ -295,6 +550,7 @@ module.exports = { markRoomDeleted, mirrorMetricEvent, mirrorRoom, + mirrorRoomChanges, mirrorUser, numericUserId, stableId, diff --git a/server/postgres/dualWrite.test.js b/server/postgres/dualWrite.test.js index 41f1990..39503fe 100644 --- a/server/postgres/dualWrite.test.js +++ b/server/postgres/dualWrite.test.js @@ -11,6 +11,7 @@ const { markRoomDeleted, mirrorMetricEvent, mirrorRoom, + mirrorRoomChanges, mirrorUser, stableId, } = require('./dualWrite'); @@ -78,7 +79,7 @@ describe('PostgreSQL dual writer', () => { scrambles: ['R U R\''], results: new Map([['1234', { time: -1, - penalties: { plusTwo: false, DNF: true }, + penalties: { DNF: true, inspection: true, AUF: false }, createdAt: updatedAt, updatedAt, }]]), @@ -101,10 +102,67 @@ describe('PostgreSQL dual writer', () => { 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[1]).toContain(-1); + 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({ 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/schema.prisma b/server/prisma/schema.prisma index ee184da..25c9a74 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -91,21 +91,23 @@ model Attempt { } 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") - penalties 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) - 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) + 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)], map: "solves_user_created_idx") + @@index([userId, sourceCreatedAt(sort: Desc), id(sort: Desc)], map: "solves_user_created_idx") @@map("solves") @@schema("app") } From 8c5d57e15ff5e64f374f9024021188794f914909 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Thu, 9 Jul 2026 17:18:11 -0700 Subject: [PATCH 5/8] Fix Vite shared module loading Move shared event and protocol constants to JSON so the Vite client uses ESM while the CommonJS server reads the same data. Import the Material UI theme provider through its supported direct package entry to avoid the optimizer's broken barrel initialization. --- client/package.json | 1 + client/src/lib/Namespace.js | 2 +- client/src/lib/events.js | 146 +----------------------- client/src/lib/events.json | 134 ++++++++++++++++++++++ client/src/lib/protocol.js | 52 +-------- client/src/lib/protocol.json | 48 ++++++++ client/src/store/middlewares/default.js | 2 +- client/src/store/middlewares/rooms.js | 2 +- client/src/theme.jsx | 2 +- server/models/room.js | 2 +- server/socket/lib/commands.js | 2 +- server/socket/namespaces/default.js | 2 +- server/socket/namespaces/rooms.js | 2 +- 13 files changed, 197 insertions(+), 200 deletions(-) create mode 100644 client/src/lib/events.json create mode 100644 client/src/lib/protocol.json diff --git a/client/package.json b/client/package.json index 0967463..d4f12c8 100644 --- a/client/package.json +++ b/client/package.json @@ -6,6 +6,7 @@ "@material-ui/core": "^4.11.2", "@material-ui/icons": "^4.11.2", "@material-ui/lab": "4.0.0-alpha.46", + "@material-ui/styles": "^4.11.5", "clsx": "1.1.0", "connected-react-router": "6.8.0", "date-fns": "^2.16.1", diff --git a/client/src/lib/Namespace.js b/client/src/lib/Namespace.js index bb2058e..e82eb79 100644 --- a/client/src/lib/Namespace.js +++ b/client/src/lib/Namespace.js @@ -1,5 +1,5 @@ // import io from 'socket.io-client'; -import * as Protocol from './protocol'; +import Protocol from './protocol'; Error.stackTraceLimit = Infinity; diff --git a/client/src/lib/events.js b/client/src/lib/events.js index 1f016a5..79bcb7f 100644 --- a/client/src/lib/events.js +++ b/client/src/lib/events.js @@ -1,145 +1,5 @@ -const Events = [ - { - id: '333', - scrambler: '333', - name: '3x3', - group: 'WCA', - }, - { - id: '222', - scrambler: '222', - name: '2x2', - group: 'WCA', - }, - { - id: '444', - scrambler: '444', - name: '4x4', - group: 'WCA', - }, - { - id: '555', - scrambler: '555', - name: '5x5', - group: 'WCA', - }, - { - id: '666', - scrambler: '666', - name: '6x6', - group: 'WCA', - }, - { - id: '777', - scrambler: '777', - name: '7x7', - group: 'WCA', - }, - { - id: '333bf', - scrambler: '333', - name: '3x3 Blindfolded', - group: 'WCA', - // }, { - // id: '333fm', - // scrambler: '333', - // name: '3x3x3 Fewest Moves', - }, - { - id: '333oh', - scrambler: '333', - name: '3x3 One-Handed', - group: 'WCA', - }, - { - id: '333ft', - scrambler: '333', - name: '3x3 With Feet', - group: 'WCA', - }, - { - id: 'minx', - scrambler: 'minx', - name: 'Megaminx', - group: 'WCA', - }, - { - id: 'pyram', - scrambler: 'pyram', - name: 'Pyraminx', - group: 'WCA', - }, - { - id: 'clock', - scrambler: 'clock', - name: 'Clock', - group: 'WCA', - }, - { - id: 'clock-optimal', - scrambler: 'clock-optimal', - name: 'Clock (Optimal)', - group: 'Other', - }, - { - id: 'skewb', - scrambler: 'skewb', - name: 'Skewb', - group: 'WCA', - }, - { - id: 'sq1', - scrambler: 'sq1', - name: 'Square-1', - group: 'WCA', - }, - { - id: '444bf', - scrambler: '444', - name: '4x4 Blindfolded', - group: 'WCA', - }, - { - id: '555bf', - scrambler: '555', - name: '5x5 Blindfolded', - group: 'WCA', - // }, { - // id: '333mbf', - // name: '3x3x3 Multi-Blind', - }, - { - id: 'fto', - scrambler: 'fto', - name: 'FTO', - group: 'Other', - }, - { - id: 'pll', - scrambler: 'pll', - name: 'PLL', - group: 'Other', - }, - { - id: 'zbll', - scrambler: 'zbll', - name: 'ZBLL', - group: 'Other', - }, - { - id: 'lse', - scrambler: 'lse', - name: 'Last Six Edges', - group: 'Other', - }, - { - id: 'ru', - scrambler: 'ru', - name: 'RU 2gen', - group: 'Other', - }, -]; +import Events from './events.json'; -module.exports.Events = Events; +export { Events }; -module.exports.getNameFromId = (eventId) => Events.find((e) => e.id === eventId).name; +export const getNameFromId = (eventId) => Events.find((event) => event.id === eventId).name; diff --git a/client/src/lib/events.json b/client/src/lib/events.json new file mode 100644 index 0000000..78cbcf5 --- /dev/null +++ b/client/src/lib/events.json @@ -0,0 +1,134 @@ +[ + { + "id": "333", + "scrambler": "333", + "name": "3x3", + "group": "WCA" + }, + { + "id": "222", + "scrambler": "222", + "name": "2x2", + "group": "WCA" + }, + { + "id": "444", + "scrambler": "444", + "name": "4x4", + "group": "WCA" + }, + { + "id": "555", + "scrambler": "555", + "name": "5x5", + "group": "WCA" + }, + { + "id": "666", + "scrambler": "666", + "name": "6x6", + "group": "WCA" + }, + { + "id": "777", + "scrambler": "777", + "name": "7x7", + "group": "WCA" + }, + { + "id": "333bf", + "scrambler": "333", + "name": "3x3 Blindfolded", + "group": "WCA" + }, + { + "id": "333oh", + "scrambler": "333", + "name": "3x3 One-Handed", + "group": "WCA" + }, + { + "id": "333ft", + "scrambler": "333", + "name": "3x3 With Feet", + "group": "WCA" + }, + { + "id": "minx", + "scrambler": "minx", + "name": "Megaminx", + "group": "WCA" + }, + { + "id": "pyram", + "scrambler": "pyram", + "name": "Pyraminx", + "group": "WCA" + }, + { + "id": "clock", + "scrambler": "clock", + "name": "Clock", + "group": "WCA" + }, + { + "id": "clock-optimal", + "scrambler": "clock-optimal", + "name": "Clock (Optimal)", + "group": "Other" + }, + { + "id": "skewb", + "scrambler": "skewb", + "name": "Skewb", + "group": "WCA" + }, + { + "id": "sq1", + "scrambler": "sq1", + "name": "Square-1", + "group": "WCA" + }, + { + "id": "444bf", + "scrambler": "444", + "name": "4x4 Blindfolded", + "group": "WCA" + }, + { + "id": "555bf", + "scrambler": "555", + "name": "5x5 Blindfolded", + "group": "WCA" + }, + { + "id": "fto", + "scrambler": "fto", + "name": "FTO", + "group": "Other" + }, + { + "id": "pll", + "scrambler": "pll", + "name": "PLL", + "group": "Other" + }, + { + "id": "zbll", + "scrambler": "zbll", + "name": "ZBLL", + "group": "Other" + }, + { + "id": "lse", + "scrambler": "lse", + "name": "Last Six Edges", + "group": "Other" + }, + { + "id": "ru", + "scrambler": "ru", + "name": "RU 2gen", + "group": "Other" + } +] diff --git a/client/src/lib/protocol.js b/client/src/lib/protocol.js index 29b44ae..b224a35 100644 --- a/client/src/lib/protocol.js +++ b/client/src/lib/protocol.js @@ -1,49 +1,3 @@ -// socket.io event names -module.exports = { - MESSAGE: 'message', - CONNECT: 'connect', - DISCONNECT: 'disconnect', - CONNECT_ERR: 'connect_error', - RECONNECT_ERR: 'reconnect_error', - RECONNECT: 'reconnect', - ERROR: 'errorrr', - UPDATE_ROOMS: 'update_rooms', - UPDATE_ROOM: 'update_room', - GLOBAL_ROOM_UPDATED: 'global_room_updated', - CREATE_ROOM: 'create_room', - DELETE_ROOM: 'delete_room', - ROOM_CREATED: 'room_created', - ROOM_DELETED: 'room_deleted', - UPDATE_ADMIN: 'update_admin', - FORCE_JOIN: 'force_join', - FORCE_LEAVE: 'force_leave', - KICKED: 'kicked', - BANNED: 'banned', - JOIN_ROOM: 'join_room', - LEAVE_ROOM: 'leave_room', - JOIN: 'join', - USER_JOIN: 'user_join', - USER_LEFT: 'user_left', - SUBMIT_RESULT: 'submit_result', - NEW_RESULT: 'new_result', - SEND_EDIT_RESULT: 'send_edit_result', - EDIT_RESULT: 'edit_result', - NEW_ATTEMPT: 'new_attempt', - REQUEST_SCRAMBLE: 'request_scramble', - CHANGE_EVENT: 'change_event', - EDIT_ROOM: 'edit_room', - UPDATE_PREFERENCES: 'update_preferences', - UPDATE_STATUS: 'update_status', - UPDATE_COMPETING: 'update_competing', - UPDATE_USER_COUNT: 'user_count', - UPDATE_USERS_IN_LOBBY: 'update_users_in_lobby', - KICK_USER: 'kick_user', - BAN_USER: 'ban_user', - UNBAN_USER: 'unban_user', - UPDATE_REGISTRATION: 'register', - NEXT_SOLVE_AT: 'next_solve_at', // transmits an approx time to expect the new scramble - START_ROOM: 'start', - PAUSE_ROOM: 'pause', - UPDATE_USER: 'update_user', - ADMIN: 'admin', -}; +import Protocol from './protocol.json'; + +export default Protocol; diff --git a/client/src/lib/protocol.json b/client/src/lib/protocol.json new file mode 100644 index 0000000..8448c6f --- /dev/null +++ b/client/src/lib/protocol.json @@ -0,0 +1,48 @@ +{ + "MESSAGE": "message", + "CONNECT": "connect", + "DISCONNECT": "disconnect", + "CONNECT_ERR": "connect_error", + "RECONNECT_ERR": "reconnect_error", + "RECONNECT": "reconnect", + "ERROR": "errorrr", + "UPDATE_ROOMS": "update_rooms", + "UPDATE_ROOM": "update_room", + "GLOBAL_ROOM_UPDATED": "global_room_updated", + "CREATE_ROOM": "create_room", + "DELETE_ROOM": "delete_room", + "ROOM_CREATED": "room_created", + "ROOM_DELETED": "room_deleted", + "UPDATE_ADMIN": "update_admin", + "FORCE_JOIN": "force_join", + "FORCE_LEAVE": "force_leave", + "KICKED": "kicked", + "BANNED": "banned", + "JOIN_ROOM": "join_room", + "LEAVE_ROOM": "leave_room", + "JOIN": "join", + "USER_JOIN": "user_join", + "USER_LEFT": "user_left", + "SUBMIT_RESULT": "submit_result", + "NEW_RESULT": "new_result", + "SEND_EDIT_RESULT": "send_edit_result", + "EDIT_RESULT": "edit_result", + "NEW_ATTEMPT": "new_attempt", + "REQUEST_SCRAMBLE": "request_scramble", + "CHANGE_EVENT": "change_event", + "EDIT_ROOM": "edit_room", + "UPDATE_PREFERENCES": "update_preferences", + "UPDATE_STATUS": "update_status", + "UPDATE_COMPETING": "update_competing", + "UPDATE_USER_COUNT": "user_count", + "UPDATE_USERS_IN_LOBBY": "update_users_in_lobby", + "KICK_USER": "kick_user", + "BAN_USER": "ban_user", + "UNBAN_USER": "unban_user", + "UPDATE_REGISTRATION": "register", + "NEXT_SOLVE_AT": "next_solve_at", + "START_ROOM": "start", + "PAUSE_ROOM": "pause", + "UPDATE_USER": "update_user", + "ADMIN": "admin" +} diff --git a/client/src/store/middlewares/default.js b/client/src/store/middlewares/default.js index ae4d7c7..366c97c 100644 --- a/client/src/store/middlewares/default.js +++ b/client/src/store/middlewares/default.js @@ -1,4 +1,4 @@ -import * as Protocol from '../../lib/protocol'; +import Protocol from '../../lib/protocol'; import Namespace from '../../lib/Namespace'; import { CONNECT, diff --git a/client/src/store/middlewares/rooms.js b/client/src/store/middlewares/rooms.js index 7155296..c0e621e 100644 --- a/client/src/store/middlewares/rooms.js +++ b/client/src/store/middlewares/rooms.js @@ -1,7 +1,7 @@ import { push } from 'connected-react-router'; import UIfx from 'uifx'; import notificationAsset from '../../assets/notification.mp3'; -import * as Protocol from '../../lib/protocol'; +import Protocol from '../../lib/protocol'; import Namespace from '../../lib/Namespace'; import { uuid } from '../../lib/utils'; import { diff --git a/client/src/theme.jsx b/client/src/theme.jsx index aeae1d0..f932545 100644 --- a/client/src/theme.jsx +++ b/client/src/theme.jsx @@ -7,8 +7,8 @@ import { } from '@material-ui/core/colors'; import { createMuiTheme, - ThemeProvider as MuiThemeProvider, } from '@material-ui/core/styles'; +import MuiThemeProvider from '@material-ui/styles/ThemeProvider'; import useMediaQuery from '@material-ui/core/useMediaQuery'; const defaultTheme = createMuiTheme(); diff --git a/server/models/room.js b/server/models/room.js index 944d28c..d9cb67f 100644 --- a/server/models/room.js +++ b/server/models/room.js @@ -2,7 +2,7 @@ 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; diff --git a/server/socket/lib/commands.js b/server/socket/lib/commands.js index 08bdc5e..dbd5e6d 100644 --- a/server/socket/lib/commands.js +++ b/server/socket/lib/commands.js @@ -1,4 +1,4 @@ -const Protocol = require('../../../client/src/lib/protocol'); +const Protocol = require('../../../client/src/lib/protocol.json'); const ChatMessage = require('./ChatMessage'); const Commands = { diff --git a/server/socket/namespaces/default.js b/server/socket/namespaces/default.js index 5266f81..e4284e0 100644 --- a/server/socket/namespaces/default.js +++ b/server/socket/namespaces/default.js @@ -1,5 +1,5 @@ const logger = require('../../logger'); -const Protocol = require('../../../client/src/lib/protocol'); +const Protocol = require('../../../client/src/lib/protocol.json'); module.exports = (io, middlewares) => { const ns = io.of('/'); diff --git a/server/socket/namespaces/rooms.js b/server/socket/namespaces/rooms.js index d45f713..862d26e 100644 --- a/server/socket/namespaces/rooms.js +++ b/server/socket/namespaces/rooms.js @@ -1,6 +1,6 @@ const _ = require('lodash'); const bcrypt = require('bcrypt'); -const Protocol = require('../../../client/src/lib/protocol'); +const Protocol = require('../../../client/src/lib/protocol.json'); const ChatMessage = require('../lib/ChatMessage'); const { parseCommand } = require('../lib/commands'); const logger = require('../../logger'); From 64dca85f76463ef2d8250c8269f284baba78ca51 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Fri, 10 Jul 2026 08:24:41 -0700 Subject: [PATCH 6/8] Preserve room sessions across deploys Delay disconnect cleanup while clients reconnect and reconcile membership through the shared Redis adapter. Deploy application services sequentially without recreating stateful dependencies. --- .env.example | 2 + README_DEPLOYMENT.md | 25 ++++ compose.yml | 1 + scripts/deploy.sh | 40 +++++- server/config/default.json | 3 +- server/index.js | 24 ---- server/runtimeConfig.js | 9 ++ server/socket/lib/reconnectGrace.js | 93 ++++++++++++++ server/socket/lib/reconnectGrace.test.js | 157 +++++++++++++++++++++++ server/socket/namespaces/rooms.js | 103 +++++++++------ 10 files changed, 393 insertions(+), 64 deletions(-) create mode 100644 server/socket/lib/reconnectGrace.js create mode 100644 server/socket/lib/reconnectGrace.test.js diff --git a/.env.example b/.env.example index 2ef5254..bc02498 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,8 @@ NODE_ENV=prod PORT=8080 SOCKETIO_PORT=9000 +# Preserve room membership while clients reconnect after a socket deploy. +ROOM_RECONNECT_GRACE_MS=60000 APP_IMAGE_TAG=local # Public URLs used by the client build diff --git a/README_DEPLOYMENT.md b/README_DEPLOYMENT.md index eba0432..0174b6c 100644 --- a/README_DEPLOYMENT.md +++ b/README_DEPLOYMENT.md @@ -87,6 +87,31 @@ Deploy latest code from the server checkout: APP_DIR=/opt/letscube scripts/deploy.sh ``` +The deploy script builds the new application image and applies PostgreSQL +migrations before replacing either application process. It then replaces and +health-checks `api` and `socket` one at a time. MongoDB, PostgreSQL, and Redis +are started without recreating existing containers. nginx is gracefully +reloaded after each application replacement so it resolves the new container +without dropping unrelated connections. + +Socket clients may briefly reconnect while the `socket` container is replaced. +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 instead of +leaving and rejoining it. Only users with no socket on any Socket.IO instance +after the grace window are removed from the room. + +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. + ## Backups And Restore Run a MongoDB backup: diff --git a/compose.yml b/compose.yml index 574303d..f2009cb 100644 --- a/compose.yml +++ b/compose.yml @@ -4,6 +4,7 @@ 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} MONGO_URL: ${MONGO_URL:-mongodb://mongo:27017/letscube} REDIS_URL: ${REDIS_URL:-redis://redis:6379} POSTGRES_ENABLED: ${POSTGRES_ENABLED:-true} diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 4f292e4..f13f378 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -36,8 +36,44 @@ if [ "$previous_commit" = "$current_commit" ] && [ "$DEPLOY_FORCE" != "true" ]; exit 0 fi -if ! "${COMPOSE[@]}" up -d --build; then - echo "Deployment failed. Recent service logs:" >&2 +if ! "${COMPOSE[@]}" build api; then + echo "Application image build failed." >&2 + exit 1 +fi + +if ! "${COMPOSE[@]}" up -d --no-recreate mongo postgres redis; then + echo "Failed to start backing services." >&2 + exit 1 +fi + +if ! "${COMPOSE[@]}" run --rm --no-deps migrate; then + echo "PostgreSQL migration failed; application containers were not replaced." >&2 + exit 1 +fi + +# Replace one application process at a time. API sessions live in MongoDB, and +# socket room membership is preserved during the configured reconnect grace. +if ! "${COMPOSE[@]}" up -d --no-deps --wait api; then + echo "API deployment failed. Recent API logs:" >&2 + "${COMPOSE[@]}" logs --tail=120 api >&2 || true + exit 1 +fi + +if ! "${COMPOSE[@]}" up -d --no-deps --wait nginx \ + || ! "${COMPOSE[@]}" exec -T nginx nginx -s reload; then + echo "Failed to reload nginx after the API deployment." >&2 + exit 1 +fi + +if ! "${COMPOSE[@]}" up -d --no-deps --wait socket; then + echo "Socket deployment failed. Recent socket logs:" >&2 + "${COMPOSE[@]}" logs --tail=120 socket >&2 || true + exit 1 +fi + +if ! "${COMPOSE[@]}" up -d --no-deps --wait nginx \ + || ! "${COMPOSE[@]}" exec -T nginx nginx -s reload; then + echo "Failed to reload nginx after the socket deployment. Recent service logs:" >&2 "${COMPOSE[@]}" logs --tail=120 api socket nginx >&2 || true exit 1 fi diff --git a/server/config/default.json b/server/config/default.json index 8c3bbd8..463f612 100644 --- a/server/config/default.json +++ b/server/config/default.json @@ -3,7 +3,8 @@ "port": 8080 }, "socketio": { - "port": 9000 + "port": 9000, + "reconnectGraceMs": 60000 }, "mongodb": "mongodb://127.0.0.1/letscube", "postgres": { diff --git a/server/index.js b/server/index.js index 3293ac0..0516e33 100644 --- a/server/index.js +++ b/server/index.js @@ -12,7 +12,6 @@ const session = require('./middlewares/session'); const logger = require('./logger'); const auth = require('./auth'); const api = require('./api'); -const { Room } = require('./models'); Error.stackTraceLimit = 100; @@ -101,26 +100,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/runtimeConfig.js b/server/runtimeConfig.js index 382b628..296228f 100644 --- a/server/runtimeConfig.js +++ b/server/runtimeConfig.js @@ -18,6 +18,11 @@ const parsePositiveInteger = (value, fallback) => { 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; @@ -58,6 +63,10 @@ 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, diff --git a/server/socket/lib/reconnectGrace.js b/server/socket/lib/reconnectGrace.js new file mode 100644 index 0000000..a755364 --- /dev/null +++ b/server/socket/lib/reconnectGrace.js @@ -0,0 +1,93 @@ +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 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..2cb2b27 --- /dev/null +++ b/server/socket/lib/reconnectGrace.test.js @@ -0,0 +1,157 @@ +/* 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('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/namespaces/rooms.js b/server/socket/namespaces/rooms.js index 862d26e..0047820 100644 --- a/server/socket/namespaces/rooms.js +++ b/server/socket/namespaces/rooms.js @@ -3,6 +3,7 @@ const bcrypt = require('bcrypt'); 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'); @@ -11,6 +12,7 @@ const { encodeUserRoom } = require('../utils'); const roomMap = require('../lib/roomMap'); const { canAccessRoom, canDeleteRoom } = require('../lib/roomAuthorization'); const { removeUserFromRoomSockets } = require('../lib/roomSockets'); +const { createReconnectGrace } = require('../lib/reconnectGrace'); const { createSafeSocketHandler, optionalAcknowledgment, @@ -61,31 +63,23 @@ 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); - }); - - room.admin = null; - await room.updateStale(true); - })); - - logger.info('Marked normal rooms stale on socket startup', { - rooms: rooms.length, - }); + return rooms.flatMap((room) => room.usersInRoom.map((user) => ({ + roomId: room._id, + userId: user.id, + leaveReason: 'disconnect', + }))); } - const normalRoomsReady = markNormalRoomsStaleOnStartup().catch((err) => { - logger.error(err); - }); - function sendNewScramble(room) { return room.newAttempt().then((r) => { logger.debug('Sending new scramble to room', { roomId: room.id }); @@ -100,6 +94,49 @@ module.exports = (io, middlewares) => { }); } + async function finalizeUserDeparture({ + roomId, userId, connectionId, leaveReason, + }) { + const room = await fetchRoom(roomId); + const userKey = userId.toString(); + + if (!room || !room.inRoom.get(userKey)) { + 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); + ns().emit(Protocol.GLOBAL_ROOM_UPDATED, roomMask(updatedRoom)); + + if (updatedRoom.doneWithScramble()) { + logger.debug('everyone done, sending new scramble'); + 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) { @@ -183,8 +220,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'}`); @@ -284,25 +319,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); - }); - - await metrics.endRoomVisit({ - room, + reconnectGrace.cancel(socket.roomId, socket.userId); + await reconnectGrace.finalize({ + roomId: socket.roomId, userId: socket.userId, connectionId: socket.id, leaveReason, - activeUserCount: room.usersLength, }); - - broadcast(Protocol.USER_LEFT, socket.user.id); - ns().emit(Protocol.GLOBAL_ROOM_UPDATED, roomMask(room)); - - if (room.doneWithScramble()) { - logger.debug('everyone done, sending new scramble'); - sendNewScramble(room); - } } catch (e) { logger.error(e); } @@ -338,6 +361,7 @@ module.exports = (io, middlewares) => { } socket.join(encodeUserRoom(socket.userId, room._id)); + reconnectGrace.cancel(room._id, socket.userId); const r = await room.addUser(socket.user, spectating, (_room) => { ns().in(_room.accessCode).emit(Protocol.UPDATE_ADMIN, _room.admin); @@ -838,7 +862,12 @@ 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('disconnect'); + reconnectGrace.schedule({ + roomId: socket.roomId, + userId: socket.userId, + connectionId: socket.id, + leaveReason: 'disconnect', + }); } else if (socket.room) { await metrics.endRoomVisit({ room: socket.room, From 516cc090e872f1ea3ba7b2a01d46cab191d79b31 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Fri, 10 Jul 2026 08:43:32 -0700 Subject: [PATCH 7/8] Add API and socket health checks Expose dependency-aware HTTP readiness endpoints and a Socket.IO health round trip. Point Compose and nginx at the explicit health routes for deployment verification. --- README_DEPLOYMENT.md | 16 +++++ client/src/lib/protocol.json | 2 + compose.yml | 4 +- nginx.conf | 4 ++ server/health.js | 56 +++++++++++++++++ server/health.test.js | 79 ++++++++++++++++++++++++ server/index.js | 18 +++++- server/socket/index.js | 27 +++++++- server/socket/namespaces/default.js | 11 +++- server/socket/namespaces/default.test.js | 60 ++++++++++++++++++ 10 files changed, 271 insertions(+), 6 deletions(-) create mode 100644 server/health.js create mode 100644 server/health.test.js create mode 100644 server/socket/namespaces/default.test.js diff --git a/README_DEPLOYMENT.md b/README_DEPLOYMENT.md index 0174b6c..0e16e5a 100644 --- a/README_DEPLOYMENT.md +++ b/README_DEPLOYMENT.md @@ -112,6 +112,22 @@ 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. +### Health Checks + +The API and socket processes expose dependency-aware health endpoints. A +healthy response has HTTP status `200`; an unavailable MongoDB, PostgreSQL, or +Redis dependency returns `503` with the failing check marked `error`. + +```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: diff --git a/client/src/lib/protocol.json b/client/src/lib/protocol.json index 8448c6f..563ae31 100644 --- a/client/src/lib/protocol.json +++ b/client/src/lib/protocol.json @@ -5,6 +5,8 @@ "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", diff --git a/compose.yml b/compose.yml index f2009cb..4a30135 100644 --- a/compose.yml +++ b/compose.yml @@ -50,7 +50,7 @@ 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 timeout: 5s retries: 5 @@ -69,7 +69,7 @@ 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 timeout: 5s retries: 5 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/server/health.js b/server/health.js new file mode 100644 index 0000000..e6d4782 --- /dev/null +++ b/server/health.js @@ -0,0 +1,56 @@ +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, check]) => ( + [name, await runCheck(check, checkTimeoutMs) ? 'ok' : 'error'] + ))); + const normalizedChecks = Object.fromEntries(checkResults); + const healthy = Object.values(normalizedChecks).every((status) => status === 'ok'); + + return { + status: healthy ? 'ok' : 'error', + service, + timestamp: now().toISOString(), + uptimeSeconds: Math.floor(uptime()), + checks: normalizedChecks, + }; +}; + +const createHealthHandler = (reportHealth) => async (_req, res) => { + const report = await reportHealth(); + + res.statusCode = report.status === 'ok' ? 200 : 503; + 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..0a074b3 --- /dev/null +++ b/server/health.test.js @@ -0,0 +1,79 @@ +/* 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('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)); + }); +}); diff --git a/server/index.js b/server/index.js index 0516e33..6deeb45 100644 --- a/server/index.js +++ b/server/index.js @@ -7,7 +7,8 @@ const passport = require('passport'); const config = require('./runtimeConfig'); const { connect } = require('./database'); -const { initializePostgres, startPostgresMaintenance } = require('./postgres'); +const { createHealthHandler, createHealthReporter } = require('./health'); +const { initializePostgres, pool, startPostgresMaintenance } = require('./postgres'); const session = require('./middlewares/session'); const logger = require('./logger'); const auth = require('./auth'); @@ -28,6 +29,21 @@ const init = async () => { await initializePostgres(); startPostgresMaintenance(); + const reportHealth = createHealthReporter({ + service: 'api', + checks: { + mongodb: () => mongoose.connection.readyState === 1, + postgres: async () => { + if (config.postgres.enabled) { + await pool.query('SELECT 1'); + } + return true; + }, + }, + }); + + app.get('/health/api', createHealthHandler(reportHealth)); + /* Logging */ app.use(morgan('combined', { diff --git a/server/socket/index.js b/server/socket/index.js index 39f7f30..68266bb 100644 --- a/server/socket/index.js +++ b/server/socket/index.js @@ -7,7 +7,8 @@ const expressSocketSession = require('express-socket.io-session'); const config = require('../runtimeConfig'); const session = require('../middlewares/session'); const { connect } = require('../database'); -const { initializePostgres } = require('../postgres'); +const { createHealthHandler, createHealthReporter } = require('../health'); +const { initializePostgres, pool } = require('../postgres'); const logger = require('../logger'); const loggerMiddleware = require('./middlewares/logger'); const authenticateMiddleware = require('./middlewares/authenticate'); @@ -65,6 +66,28 @@ const init = async () => { io.adapter(createAdapter(pubClient, subClient)); + const reportHealth = createHealthReporter({ + service: 'socket', + checks: { + mongodb: () => mongoose.connection.readyState === 1, + postgres: 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, @@ -74,7 +97,7 @@ const init = async () => { ]; initRooms(io, middlewares); - initDefault(io, middlewares); + initDefault(io, middlewares, reportHealth); watchNamespaceErrors(io, '/'); watchNamespaceErrors(io, '/rooms'); diff --git a/server/socket/namespaces/default.js b/server/socket/namespaces/default.js index e4284e0..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.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); + }); +}); From 366254c8755599ed53d2b4b58f11a82f7ee9ec5a Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Fri, 10 Jul 2026 22:03:24 -0700 Subject: [PATCH 8/8] Preserve active solves across deploys Persist and retry completed solves with idempotent acknowledgements across socket reconnects. Roll services selectively with health-gated rollback so routine deploys minimize room disruption. --- .env.example | 4 + README_DEPLOYMENT.md | 115 ++++- client/src/components/Navigation.jsx | 143 +++++- client/src/components/Navigation.test.jsx | 101 ++++ client/src/components/Room/Common/Main.jsx | 112 ++++- .../src/components/Room/Common/Main.test.jsx | 180 +++++++ .../Room/GrandPrix/GrandPrixMain.jsx | 23 +- client/src/components/Timer/ManualTimer.jsx | 9 +- .../src/components/Timer/ManualTimer.test.jsx | 47 ++ client/src/components/Timer/StackmatTimer.jsx | 4 + client/src/store/middlewares/rooms.js | 397 +++++++++++++++- client/src/store/middlewares/rooms.test.js | 444 ++++++++++++++++++ client/src/store/room/actions.js | 36 +- client/src/store/room/reducer.js | 96 +++- client/src/store/room/reducer.test.js | 66 +++ client/src/store/room/resultOutbox.js | 152 ++++++ client/src/store/room/resultOutbox.test.js | 121 +++++ compose.prod.yml | 3 + compose.yml | 3 + scripts/classify-deploy-target.sh | 41 ++ scripts/deploy.sh | 286 +++++++++-- scripts/test-deploy-classifier.sh | 31 ++ scripts/test-deploy.sh | 275 +++++++++++ server/health.js | 28 +- server/health.test.js | 69 +++ server/index.js | 13 +- server/models/room.js | 1 + server/models/room.test.js | 20 + server/runtimeConfig.js | 3 + server/runtimeConfig.test.js | 32 ++ server/socket/index.js | 13 +- server/socket/lib/reconnectGrace.js | 5 + server/socket/lib/reconnectGrace.test.js | 30 ++ server/socket/lib/resultSubmission.js | 219 +++++++++ server/socket/lib/resultSubmission.test.js | 397 ++++++++++++++++ server/socket/lib/roomAvailability.js | 5 + server/socket/lib/roomAvailability.test.js | 15 + server/socket/namespaces/rooms.js | 261 +++++++--- 38 files changed, 3600 insertions(+), 200 deletions(-) create mode 100644 client/src/components/Navigation.test.jsx create mode 100644 client/src/components/Room/Common/Main.test.jsx create mode 100644 client/src/components/Timer/ManualTimer.test.jsx create mode 100644 client/src/store/middlewares/rooms.test.js create mode 100644 client/src/store/room/reducer.test.js create mode 100644 client/src/store/room/resultOutbox.js create mode 100644 client/src/store/room/resultOutbox.test.js create mode 100755 scripts/classify-deploy-target.sh create mode 100755 scripts/test-deploy-classifier.sh create mode 100755 scripts/test-deploy.sh create mode 100644 server/runtimeConfig.test.js create mode 100644 server/socket/lib/resultSubmission.js create mode 100644 server/socket/lib/resultSubmission.test.js create mode 100644 server/socket/lib/roomAvailability.js create mode 100644 server/socket/lib/roomAvailability.test.js diff --git a/.env.example b/.env.example index bc02498..4f826cd 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,10 @@ 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 diff --git a/README_DEPLOYMENT.md b/README_DEPLOYMENT.md index 0e16e5a..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,20 +94,78 @@ Deploy latest code from the server checkout: APP_DIR=/opt/letscube scripts/deploy.sh ``` -The deploy script builds the new application image and applies PostgreSQL -migrations before replacing either application process. It then replaces and -health-checks `api` and `socket` one at a time. MongoDB, PostgreSQL, and Redis -are started without recreating existing containers. nginx is gracefully -reloaded after each application replacement so it resolves the new container -without dropping unrelated connections. +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: -Socket clients may briefly reconnect while the `socket` container is replaced. -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 instead of -leaving and rejoining it. Only users with no socket on any Socket.IO instance +```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: @@ -112,11 +177,23 @@ 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. A -healthy response has HTTP status `200`; an unavailable MongoDB, PostgreSQL, or -Redis dependency returns `503` with the failing check marked `error`. +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 @@ -163,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/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/store/middlewares/rooms.js b/client/src/store/middlewares/rooms.js index c0e621e..f65f321 100644 --- a/client/src/store/middlewares/rooms.js +++ b/client/src/store/middlewares/rooms.js @@ -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/compose.prod.yml b/compose.prod.yml index a76a6bb..f75bc21 100644 --- a/compose.prod.yml +++ b/compose.prod.yml @@ -21,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 4a30135..e22418e 100644 --- a/compose.yml +++ b/compose.yml @@ -5,6 +5,7 @@ x-app-environment: &app-environment 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} @@ -52,6 +53,7 @@ services: healthcheck: 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 @@ -71,6 +73,7 @@ services: healthcheck: 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 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 f13f378..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,52 +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 +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." >&2 + echo "Application image build failed; no application containers were replaced." >&2 + print_retry_hint exit 1 fi -if ! "${COMPOSE[@]}" up -d --no-recreate mongo postgres redis; then - echo "Failed to start backing services." >&2 +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 -# Replace one application process at a time. API sessions live in MongoDB, and -# socket room membership is preserved during the configured reconnect grace. -if ! "${COMPOSE[@]}" up -d --no-deps --wait api; then - echo "API deployment failed. Recent API logs:" >&2 - "${COMPOSE[@]}" logs --tail=120 api >&2 || true - exit 1 -fi +declare -A rollback_image_ids=() +declare -A rollback_tags=() +deploy_id="$(date -u +%Y%m%dT%H%M%SZ)-$$" -if ! "${COMPOSE[@]}" up -d --no-deps --wait nginx \ - || ! "${COMPOSE[@]}" exec -T nginx nginx -s reload; then - echo "Failed to reload nginx after the API deployment." >&2 - exit 1 -fi +snapshot_service_image() { + local service="$1" + local container_id + local image_id + local rollback_tag -if ! "${COMPOSE[@]}" up -d --no-deps --wait socket; then - echo "Socket deployment failed. Recent socket logs:" >&2 - "${COMPOSE[@]}" logs --tail=120 socket >&2 || true - exit 1 -fi + 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 -if ! "${COMPOSE[@]}" up -d --no-deps --wait nginx \ - || ! "${COMPOSE[@]}" exec -T nginx nginx -s reload; then - echo "Failed to reload nginx after the socket deployment. Recent service logs:" >&2 - "${COMPOSE[@]}" logs --tail=120 api socket nginx >&2 || true - exit 1 -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/health.js b/server/health.js index e6d4782..e10c3a8 100644 --- a/server/health.js +++ b/server/health.js @@ -26,14 +26,30 @@ const createHealthReporter = ({ now = () => new Date(), uptime = () => process.uptime(), }) => async () => { - const checkResults = await Promise.all(Object.entries(checks).map(async ([name, check]) => ( - [name, await runCheck(check, checkTimeoutMs) ? 'ok' : 'error'] + 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 normalizedChecks = Object.fromEntries(checkResults); - const healthy = Object.values(normalizedChecks).every((status) => status === 'ok'); + 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: healthy ? 'ok' : 'error', + status, service, timestamp: now().toISOString(), uptimeSeconds: Math.floor(uptime()), @@ -44,7 +60,7 @@ const createHealthReporter = ({ const createHealthHandler = (reportHealth) => async (_req, res) => { const report = await reportHealth(); - res.statusCode = report.status === 'ok' ? 200 : 503; + 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)); diff --git a/server/health.test.js b/server/health.test.js index 0a074b3..48880b2 100644 --- a/server/health.test.js +++ b/server/health.test.js @@ -54,6 +54,57 @@ describe('health reporting', () => { }); }); + 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', @@ -76,4 +127,22 @@ describe('health reporting', () => { ); 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 6deeb45..95d74fa 100644 --- a/server/index.js +++ b/server/index.js @@ -33,11 +33,14 @@ const init = async () => { service: 'api', checks: { mongodb: () => mongoose.connection.readyState === 1, - postgres: async () => { - if (config.postgres.enabled) { - await pool.query('SELECT 1'); - } - return true; + postgres: { + required: false, + check: async () => { + if (config.postgres.enabled) { + await pool.query('SELECT 1'); + } + return true; + }, }, }, }); diff --git a/server/models/room.js b/server/models/room.js index d9cb67f..366f6e8 100644 --- a/server/models/room.js +++ b/server/models/room.js @@ -18,6 +18,7 @@ const Result = new mongoose.Schema({ required: true, }, penalties: Object, + submissionId: String, }, { timestamps: true, }); diff --git a/server/models/room.test.js b/server/models/room.test.js index a28039a..131ad2b 100644 --- a/server/models/room.test.js +++ b/server/models/room.test.js @@ -52,6 +52,26 @@ describe('room security helpers', () => { 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', diff --git a/server/runtimeConfig.js b/server/runtimeConfig.js index 296228f..16ed0f2 100644 --- a/server/runtimeConfig.js +++ b/server/runtimeConfig.js @@ -94,6 +94,9 @@ module.exports = { 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, 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 68266bb..dc4ebff 100644 --- a/server/socket/index.js +++ b/server/socket/index.js @@ -70,11 +70,14 @@ const init = async () => { service: 'socket', checks: { mongodb: () => mongoose.connection.readyState === 1, - postgres: async () => { - if (config.postgres.enabled) { - await pool.query('SELECT 1'); - } - return true; + postgres: { + required: false, + check: async () => { + if (config.postgres.enabled) { + await pool.query('SELECT 1'); + } + return true; + }, }, redis: async () => pubClient.status === 'ready' && subClient.status === 'ready' diff --git a/server/socket/lib/reconnectGrace.js b/server/socket/lib/reconnectGrace.js index a755364..162b2ed 100644 --- a/server/socket/lib/reconnectGrace.js +++ b/server/socket/lib/reconnectGrace.js @@ -19,6 +19,11 @@ const createReconnectGrace = ({ clearTimer(timer); timers.delete(key); } + + const finalization = finalizationQueues.get(roomId.toString()); + return finalization + ? finalization.then(() => true, () => true) + : Promise.resolve(false); }; const enqueueFinalization = (departure, requireInactive) => { diff --git a/server/socket/lib/reconnectGrace.test.js b/server/socket/lib/reconnectGrace.test.js index 2cb2b27..0d5cf8b 100644 --- a/server/socket/lib/reconnectGrace.test.js +++ b/server/socket/lib/reconnectGrace.test.js @@ -70,6 +70,36 @@ describe('reconnect grace', () => { 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(); 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/rooms.js b/server/socket/namespaces/rooms.js index 0047820..e61f614 100644 --- a/server/socket/namespaces/rooms.js +++ b/server/socket/namespaces/rooms.js @@ -11,8 +11,13 @@ 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, @@ -37,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 = {}; @@ -80,20 +87,33 @@ module.exports = (io, middlewares) => { }))); } - 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], - }); + 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; + } - return r; - }).catch((err) => { + function sendNewScramble(room) { + return createAndSendNewScramble(room).catch((err) => { logger.error(err); }); } + const resultSubmissions = createResultSubmissionService({ + advanceRoom: createAndSendNewScramble, + fetchRoom, + }); + + function sendGlobalRoomUpdate(room) { + if (isRoomTypeEnabled(room.type)) { + ns().emit(Protocol.GLOBAL_ROOM_UPDATED, roomMask(room)); + } + } + async function finalizeUserDeparture({ roomId, userId, connectionId, leaveReason, }) { @@ -104,6 +124,11 @@ module.exports = (io, middlewares) => { 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); }); @@ -117,11 +142,11 @@ module.exports = (io, middlewares) => { }); ns().in(updatedRoom.accessCode).emit(Protocol.USER_LEFT, userId); - ns().emit(Protocol.GLOBAL_ROOM_UPDATED, roomMask(updatedRoom)); + sendGlobalRoomUpdate(updatedRoom); if (updatedRoom.doneWithScramble()) { logger.debug('everyone done, sending new scramble'); - sendNewScramble(updatedRoom); + await sendNewScramble(updatedRoom); } return true; @@ -192,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([])] @@ -361,22 +388,29 @@ module.exports = (io, middlewares) => { } socket.join(encodeUserRoom(socket.userId, room._id)); - reconnectGrace.cancel(room._id, socket.userId); + 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 - socket.room = room; + // The user is already present in persisted room state. + socket.room = activeRoom; await metrics.beginRoomVisit({ - room, + room: activeRoom, userId: socket.userId, connectionId: socket.id, - activeUserCount: room.usersLength, + activeUserCount: activeRoom.usersLength, }); - return cb(null, joinRoomMask(room)); + return cb(null, joinRoomMask(activeRoom)); } socket.room = r; @@ -390,12 +424,12 @@ module.exports = (io, middlewares) => { 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); } } @@ -423,6 +457,13 @@ module.exports = (io, middlewares) => { }); } + if (!isRoomTypeEnabled(room.type)) { + return rejectJoin('grand_prix_disabled', { + statusCode: 403, + message: 'Grand Prix rooms are disabled', + }, room); + } + if (room.private && !password) { return rejectJoin('password_required', { statusCode: 403, @@ -473,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', }); } @@ -594,51 +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; } - const previousResult = socket.room.attempts[id].results.get(socket.user.id.toString()); - socket.room.attempts[id].results.set(socket.user.id.toString(), result); - socket.room.waitingFor.set(socket.user.id.toString(), false); - - const r = await socket.room.save(); + return undefined; + }; - if (!previousResult) { - await metrics.recordRoomResult({ - room: r, - userId: socket.userId, - }); - } + 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) => { @@ -683,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); }); @@ -691,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)); @@ -702,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; } @@ -712,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)); @@ -723,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 { @@ -766,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'); @@ -800,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'); @@ -824,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); }