From 1cefa89aee146956c228a1616851e7bcb76fe8b4 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Thu, 9 Jul 2026 16:07:51 -0700 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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/5] 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');