From 46ee34b911067644aecb8b15b4eaf4b35c610340 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Wed, 8 Jul 2026 14:31:26 -0700 Subject: [PATCH 1/2] Persist user solves Archive submitted and edited room results into a durable solves collection with room, attempt, and scramble context. Add a backfill command for existing embedded room results so historical solves can be preserved before room TTL cleanup. --- server/middlewares/auth.js | 8 +-- server/models/index.js | 2 + server/models/solve.js | 86 +++++++++++++++++++++++++++++++ server/package.json | 1 + server/scripts/backfill-solves.js | 63 ++++++++++++++++++++++ server/services/solveArchive.js | 66 ++++++++++++++++++++++++ server/socket/namespaces/rooms.js | 3 ++ 7 files changed, 225 insertions(+), 4 deletions(-) create mode 100644 server/models/solve.js create mode 100644 server/scripts/backfill-solves.js create mode 100644 server/services/solveArchive.js diff --git a/server/middlewares/auth.js b/server/middlewares/auth.js index 5fb727f..8cf8bc0 100644 --- a/server/middlewares/auth.js +++ b/server/middlewares/auth.js @@ -6,8 +6,8 @@ module.exports = (req, res, next) => { res.send( JSON.stringify({ code: 403, - message: "Unauthorized", - }) + message: 'Unauthorized', + }), ); res.end(); } else if (req && req.user && req.user.id && banned.includes(req.user.id)) { @@ -15,8 +15,8 @@ module.exports = (req, res, next) => { res.send( JSON.stringify({ code: 403, - message: "Unauthorized", - }) + message: 'Unauthorized', + }), ); res.end(); } else { diff --git a/server/models/index.js b/server/models/index.js index 86f1922..fb18310 100644 --- a/server/models/index.js +++ b/server/models/index.js @@ -1,9 +1,11 @@ const mongoose = require('mongoose'); const { Attempt, Room } = require('./room'); +const Solve = require('./solve'); const User = require('./user'); module.exports = { Room: mongoose.model('Room', Room, 'rooms'), User: mongoose.model('User', User, 'users'), Attempt: mongoose.model('Attempt', Attempt, 'attempts'), + Solve: mongoose.model('Solve', Solve, 'solves'), }; diff --git a/server/models/solve.js b/server/models/solve.js new file mode 100644 index 0000000..f7995d1 --- /dev/null +++ b/server/models/solve.js @@ -0,0 +1,86 @@ +const mongoose = require('mongoose'); + +const Result = new mongoose.Schema({ + time: { + type: Number, + required: true, + }, + penalties: Object, +}, { + _id: false, + timestamps: true, +}); + +const RoomSnapshot = new mongoose.Schema({ + name: { + type: String, + required: true, + }, + event: { + type: String, + required: true, + }, + type: { + type: String, + required: true, + }, +}, { + _id: false, +}); + +const Solve = new mongoose.Schema({ + room: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Room', + required: true, + }, + roomSnapshot: { + type: RoomSnapshot, + required: true, + }, + attemptId: { + type: Number, + required: true, + }, + attempt: { + type: mongoose.Schema.Types.ObjectId, + required: true, + }, + scrambles: { + type: [String], + required: true, + }, + userId: { + type: Number, + required: true, + }, + result: { + type: Result, + required: true, + }, + editedBy: { + type: Number, + }, +}, { + timestamps: true, +}); + +Solve.index({ + room: 1, + attemptId: 1, + userId: 1, +}, { + unique: true, +}); + +Solve.index({ + userId: 1, + createdAt: -1, +}); + +Solve.index({ + 'roomSnapshot.event': 1, + createdAt: -1, +}); + +module.exports = Solve; diff --git a/server/package.json b/server/package.json index 9202b1f..8281338 100644 --- a/server/package.json +++ b/server/package.json @@ -35,6 +35,7 @@ "winston-daily-rotate-file": "4.4.2" }, "scripts": { + "backfill:solves": "node scripts/backfill-solves.js", "start:server": "nodemon index.js", "start:socket": "nodemon socket/", "lint": "eslint ./", diff --git a/server/scripts/backfill-solves.js b/server/scripts/backfill-solves.js new file mode 100644 index 0000000..f03e0b9 --- /dev/null +++ b/server/scripts/backfill-solves.js @@ -0,0 +1,63 @@ +const { connect } = require('../database'); +const logger = require('../logger'); +const { Room } = require('../models'); +const { persistSolve } = require('../services/solveArchive'); + +function resultEntries(attempt) { + if (!attempt.results) { + return []; + } + + if (attempt.results instanceof Map) { + return [...attempt.results.entries()]; + } + + return Object.keys(attempt.results).map((userId) => [userId, attempt.results[userId]]); +} + +async function backfillSolves() { + await connect(); + + let rooms = 0; + let attempts = 0; + let solves = 0; + + const roomsToBackfill = await Room.find(); + const solveWrites = []; + + roomsToBackfill.forEach((room) => { + rooms += 1; + + room.attempts.forEach((attempt) => { + attempts += 1; + + resultEntries(attempt).forEach(([userId, result]) => { + solves += 1; + solveWrites.push(persistSolve(room, attempt.id, Number(userId), result, undefined, { + throwOnError: true, + })); + }); + }); + }); + + await Promise.all(solveWrites); + + logger.info('Finished backfilling persisted solves', { + rooms, + attempts, + solves, + }); +} + +if (require.main === module) { + backfillSolves() + .then(() => { + process.exit(0); + }) + .catch((err) => { + logger.error('Failed to backfill persisted solves', err); + process.exit(1); + }); +} + +module.exports = backfillSolves; diff --git a/server/services/solveArchive.js b/server/services/solveArchive.js new file mode 100644 index 0000000..330db86 --- /dev/null +++ b/server/services/solveArchive.js @@ -0,0 +1,66 @@ +const logger = require('../logger'); +const { Solve } = require('../models'); + +function getAttempt(room, attemptId) { + return room.attempts.find((attempt) => attempt.id === attemptId); +} + +function buildSolve(room, attempt, userId, result, editedBy) { + return { + room: room._id, + roomSnapshot: { + name: room.name, + event: room.event, + type: room.type, + }, + attemptId: attempt.id, + attempt: attempt._id, + scrambles: attempt.scrambles, + userId, + result, + editedBy, + }; +} + +async function persistSolve(room, attemptId, userId, result, editedBy, options = {}) { + const attempt = getAttempt(room, attemptId); + + if (!attempt) { + const err = new Error('Attempted to archive solve for missing attempt'); + logger.error('Attempted to archive solve for missing attempt', { + roomId: room._id, + attemptId, + userId, + }); + if (options.throwOnError) { + throw err; + } + return; + } + + try { + await Solve.updateOne({ + room: room._id, + attemptId, + userId, + }, { + $set: buildSolve(room, attempt, userId, result, editedBy), + }, { + upsert: true, + }); + } catch (err) { + logger.error('Failed to archive solve result', { + err, + roomId: room._id, + attemptId, + userId, + }); + if (options.throwOnError) { + throw err; + } + } +} + +module.exports = { + persistSolve, +}; diff --git a/server/socket/namespaces/rooms.js b/server/socket/namespaces/rooms.js index 23ddf8b..21f28df 100644 --- a/server/socket/namespaces/rooms.js +++ b/server/socket/namespaces/rooms.js @@ -5,6 +5,7 @@ const ChatMessage = require('../lib/ChatMessage'); const { parseCommand } = require('../lib/commands'); const logger = require('../../logger'); const { Room, User } = require('../../models'); +const { persistSolve } = require('../../services/solveArchive'); const { encodeUserRoom } = require('../utils'); const roomMap = require('../lib/roomMap'); @@ -480,6 +481,7 @@ module.exports = (io, middlewares) => { socket.room.waitingFor.set(socket.user.id.toString(), false); const r = await socket.room.save(); + await persistSolve(r, id, socket.user.id, result, socket.user.id); ns().in(r.accessCode).emit(Protocol.NEW_RESULT, { id, @@ -524,6 +526,7 @@ module.exports = (io, middlewares) => { socket.room.attempts[result.id].results.set(userId.toString(), result.result); const r = await socket.room.save(); + await persistSolve(r, result.id, userId, result.result, socket.user.id); ns().in(r.accessCode).emit(Protocol.EDIT_RESULT, { ...result, From c919e79b0d8a30e087e2049aee51593d73790845 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Wed, 8 Jul 2026 14:40:13 -0700 Subject: [PATCH 2/2] Hide expired rooms instead of deleting them Replace the solve archive approach with retained room documents: normal rooms now use hiddenAt for lobby visibility and clear expireAt so MongoDB TTL cleanup no longer removes their attempts/results. Room deletion now hides the room document, and lobby queries exclude hidden rooms while existing solve data remains embedded on the room. --- server/models/index.js | 2 - server/models/room.js | 36 +++++++++++-- server/models/solve.js | 86 ------------------------------- server/package.json | 1 - server/scripts/backfill-solves.js | 63 ---------------------- server/services/solveArchive.js | 66 ------------------------ server/socket/namespaces/rooms.js | 75 ++++++++++++++++++++++----- 7 files changed, 93 insertions(+), 236 deletions(-) delete mode 100644 server/models/solve.js delete mode 100644 server/scripts/backfill-solves.js delete mode 100644 server/services/solveArchive.js diff --git a/server/models/index.js b/server/models/index.js index fb18310..86f1922 100644 --- a/server/models/index.js +++ b/server/models/index.js @@ -1,11 +1,9 @@ const mongoose = require('mongoose'); const { Attempt, Room } = require('./room'); -const Solve = require('./solve'); const User = require('./user'); module.exports = { Room: mongoose.model('Room', Room, 'rooms'), User: mongoose.model('User', User, 'users'), Attempt: mongoose.model('Attempt', Attempt, 'attempts'), - Solve: mongoose.model('Solve', Solve, 'solves'), }; diff --git a/server/models/room.js b/server/models/room.js index 7105bdc..a086b22 100644 --- a/server/models/room.js +++ b/server/models/room.js @@ -109,6 +109,10 @@ const Room = new mongoose.Schema({ type: Date, default: undefined, }, + hiddenAt: { + type: Date, + default: undefined, + }, twitchChannel: { type: String, default: undefined, @@ -118,11 +122,19 @@ const Room = new mongoose.Schema({ }); Room.index({ - expireAt: 1, -}, { - expireAfterSeconds: 0, + hiddenAt: 1, }); +Room.statics.visibleQuery = function visibleQuery(now = new Date()) { + return { + $or: [ + { hiddenAt: { $exists: false } }, + { hiddenAt: null }, + { hiddenAt: { $gt: now } }, + ], + }; +}; + Room.virtual('scrambler').get(function () { return new Scrambow().setType(Events.find((e) => e.id === this.event).scrambler); }); @@ -147,6 +159,10 @@ Room.virtual('latestAttempt').get(function () { return this.attempts[this.attempts.length - 1]; }); +Room.methods.isHidden = function isHidden(now = new Date()) { + return this.hiddenAt && this.hiddenAt <= now; +}; + Room.methods.start = function () { this.started = true; @@ -166,15 +182,25 @@ Room.methods.pause = function () { }; Room.methods.updateStale = function updateStale(stale) { + this.expireAt = null; + if (stale) { - this.expireAt = moment().add(10, 'minutes'); + if (!this.hiddenAt) { + this.hiddenAt = moment().add(10, 'minutes').toDate(); + } } else { - this.expireAt = null; + this.hiddenAt = null; } return this.save(); }; +Room.methods.hide = function hide() { + this.expireAt = null; + this.hiddenAt = new Date(); + return this.save(); +}; + Room.methods.addUser = async function (user, spectating, updateAdmin) { if (this.inRoom.get(user.id.toString())) { return false; diff --git a/server/models/solve.js b/server/models/solve.js deleted file mode 100644 index f7995d1..0000000 --- a/server/models/solve.js +++ /dev/null @@ -1,86 +0,0 @@ -const mongoose = require('mongoose'); - -const Result = new mongoose.Schema({ - time: { - type: Number, - required: true, - }, - penalties: Object, -}, { - _id: false, - timestamps: true, -}); - -const RoomSnapshot = new mongoose.Schema({ - name: { - type: String, - required: true, - }, - event: { - type: String, - required: true, - }, - type: { - type: String, - required: true, - }, -}, { - _id: false, -}); - -const Solve = new mongoose.Schema({ - room: { - type: mongoose.Schema.Types.ObjectId, - ref: 'Room', - required: true, - }, - roomSnapshot: { - type: RoomSnapshot, - required: true, - }, - attemptId: { - type: Number, - required: true, - }, - attempt: { - type: mongoose.Schema.Types.ObjectId, - required: true, - }, - scrambles: { - type: [String], - required: true, - }, - userId: { - type: Number, - required: true, - }, - result: { - type: Result, - required: true, - }, - editedBy: { - type: Number, - }, -}, { - timestamps: true, -}); - -Solve.index({ - room: 1, - attemptId: 1, - userId: 1, -}, { - unique: true, -}); - -Solve.index({ - userId: 1, - createdAt: -1, -}); - -Solve.index({ - 'roomSnapshot.event': 1, - createdAt: -1, -}); - -module.exports = Solve; diff --git a/server/package.json b/server/package.json index 8281338..9202b1f 100644 --- a/server/package.json +++ b/server/package.json @@ -35,7 +35,6 @@ "winston-daily-rotate-file": "4.4.2" }, "scripts": { - "backfill:solves": "node scripts/backfill-solves.js", "start:server": "nodemon index.js", "start:socket": "nodemon socket/", "lint": "eslint ./", diff --git a/server/scripts/backfill-solves.js b/server/scripts/backfill-solves.js deleted file mode 100644 index f03e0b9..0000000 --- a/server/scripts/backfill-solves.js +++ /dev/null @@ -1,63 +0,0 @@ -const { connect } = require('../database'); -const logger = require('../logger'); -const { Room } = require('../models'); -const { persistSolve } = require('../services/solveArchive'); - -function resultEntries(attempt) { - if (!attempt.results) { - return []; - } - - if (attempt.results instanceof Map) { - return [...attempt.results.entries()]; - } - - return Object.keys(attempt.results).map((userId) => [userId, attempt.results[userId]]); -} - -async function backfillSolves() { - await connect(); - - let rooms = 0; - let attempts = 0; - let solves = 0; - - const roomsToBackfill = await Room.find(); - const solveWrites = []; - - roomsToBackfill.forEach((room) => { - rooms += 1; - - room.attempts.forEach((attempt) => { - attempts += 1; - - resultEntries(attempt).forEach(([userId, result]) => { - solves += 1; - solveWrites.push(persistSolve(room, attempt.id, Number(userId), result, undefined, { - throwOnError: true, - })); - }); - }); - }); - - await Promise.all(solveWrites); - - logger.info('Finished backfilling persisted solves', { - rooms, - attempts, - solves, - }); -} - -if (require.main === module) { - backfillSolves() - .then(() => { - process.exit(0); - }) - .catch((err) => { - logger.error('Failed to backfill persisted solves', err); - process.exit(1); - }); -} - -module.exports = backfillSolves; diff --git a/server/services/solveArchive.js b/server/services/solveArchive.js deleted file mode 100644 index 330db86..0000000 --- a/server/services/solveArchive.js +++ /dev/null @@ -1,66 +0,0 @@ -const logger = require('../logger'); -const { Solve } = require('../models'); - -function getAttempt(room, attemptId) { - return room.attempts.find((attempt) => attempt.id === attemptId); -} - -function buildSolve(room, attempt, userId, result, editedBy) { - return { - room: room._id, - roomSnapshot: { - name: room.name, - event: room.event, - type: room.type, - }, - attemptId: attempt.id, - attempt: attempt._id, - scrambles: attempt.scrambles, - userId, - result, - editedBy, - }; -} - -async function persistSolve(room, attemptId, userId, result, editedBy, options = {}) { - const attempt = getAttempt(room, attemptId); - - if (!attempt) { - const err = new Error('Attempted to archive solve for missing attempt'); - logger.error('Attempted to archive solve for missing attempt', { - roomId: room._id, - attemptId, - userId, - }); - if (options.throwOnError) { - throw err; - } - return; - } - - try { - await Solve.updateOne({ - room: room._id, - attemptId, - userId, - }, { - $set: buildSolve(room, attempt, userId, result, editedBy), - }, { - upsert: true, - }); - } catch (err) { - logger.error('Failed to archive solve result', { - err, - roomId: room._id, - attemptId, - userId, - }); - if (options.throwOnError) { - throw err; - } - } -} - -module.exports = { - persistSolve, -}; diff --git a/server/socket/namespaces/rooms.js b/server/socket/namespaces/rooms.js index 21f28df..15e5f24 100644 --- a/server/socket/namespaces/rooms.js +++ b/server/socket/namespaces/rooms.js @@ -5,7 +5,6 @@ const ChatMessage = require('../lib/ChatMessage'); const { parseCommand } = require('../lib/commands'); const logger = require('../../logger'); const { Room, User } = require('../../models'); -const { persistSolve } = require('../../services/solveArchive'); const { encodeUserRoom } = require('../utils'); const roomMap = require('../lib/roomMap'); @@ -29,12 +28,13 @@ const joinRoomMask = _.partial(_.pick, _, privateRoomKeys); const fetchRoom = (id) => Room.findById({ _id: id }).populate('users').populate('admin').populate('owner'); -const getRooms = (userId) => Room.find().populate('users').populate('admin').populate('owner') +const getRooms = (userId) => Room.find(Room.visibleQuery()).populate('users').populate('admin').populate('owner') .then((rooms) => rooms.filter((room) => ( userId ? !room.banned.get(userId.toString()) : true )).map(roomMask)); const roomTimerObj = {}; +const roomHideTimerObj = {}; module.exports = (io, middlewares) => { const ns = () => io.of('/rooms'); @@ -52,6 +52,36 @@ module.exports = (io, middlewares) => { return [...sockets].some((socketId) => socketId !== currentSocketId); }; + function clearRoomHideTimer(roomId) { + clearTimeout(roomHideTimerObj[roomId]); + delete roomHideTimerObj[roomId]; + } + + function scheduleRoomHide(room) { + if (!room.hiddenAt) { + clearRoomHideTimer(room._id); + return; + } + + clearRoomHideTimer(room._id); + + const time = new Date(room.hiddenAt).getTime() - Date.now(); + + roomHideTimerObj[room._id] = setTimeout(async () => { + try { + const r = await fetchRoom(room._id); + + if (r && r.isHidden()) { + ns().emit(Protocol.ROOM_DELETED, r._id.toString()); + } + } catch (err) { + logger.error(err); + } finally { + clearRoomHideTimer(room._id); + } + }, Math.max(time, 0)); + } + async function markNormalRoomsStaleOnStartup() { const rooms = await Room.find({ type: 'normal' }) .populate('users') @@ -65,7 +95,8 @@ module.exports = (io, middlewares) => { }); room.admin = null; - await room.updateStale(true); + const r = await room.updateStale(true); + scheduleRoomHide(r); })); logger.info('Marked normal rooms stale on socket startup', { @@ -256,6 +287,7 @@ module.exports = (io, middlewares) => { broadcast(Protocol.USER_LEFT, socket.user.id); ns().emit(Protocol.GLOBAL_ROOM_UPDATED, roomMask(room)); + scheduleRoomHide(room); if (room.doneWithScramble()) { logger.debug('everyone done, sending new scramble'); @@ -295,6 +327,7 @@ module.exports = (io, middlewares) => { } socket.room = r; + clearRoomHideTimer(r._id); socket.emit(Protocol.JOIN, joinRoomMask(r)); cb(null, joinRoomMask(r)); @@ -318,6 +351,13 @@ module.exports = (io, middlewares) => { }); } + if (room.isHidden()) { + return cb({ + statusCode: 404, + message: `Could not find room with id ${id}`, + }); + } + if (room.private && !password) { return cb({ statusCode: 403, @@ -409,15 +449,24 @@ module.exports = (io, middlewares) => { }); } - Room.deleteOne({ _id: id }).then((res) => { - if (res.deletedCount > 0) { - socket.room = undefined; - cb(null); - ns().emit(Protocol.ROOM_DELETED, id); - } else if (res.deletedCount > 1) { - logger.error(168, 'big problemo'); + try { + const room = await Room.findById(id); + + if (!room) { + return cb({ + statusCode: 404, + message: `Could not find room with id ${id}`, + }); } - }); + + await room.hide(); + clearRoomHideTimer(room._id); + socket.room = undefined; + cb(null); + ns().emit(Protocol.ROOM_DELETED, id); + } catch (e) { + logger.error(e); + } }); // Register user for room they are currently in @@ -481,7 +530,6 @@ module.exports = (io, middlewares) => { socket.room.waitingFor.set(socket.user.id.toString(), false); const r = await socket.room.save(); - await persistSolve(r, id, socket.user.id, result, socket.user.id); ns().in(r.accessCode).emit(Protocol.NEW_RESULT, { id, @@ -526,7 +574,6 @@ module.exports = (io, middlewares) => { socket.room.attempts[result.id].results.set(userId.toString(), result.result); const r = await socket.room.save(); - await persistSolve(r, result.id, userId, result.result, socket.user.id); ns().in(r.accessCode).emit(Protocol.EDIT_RESULT, { ...result, @@ -624,6 +671,7 @@ module.exports = (io, middlewares) => { ns().in(socket.room.accessCode).emit(Protocol.USER_LEFT, userId); ns().emit(Protocol.GLOBAL_ROOM_UPDATED, roomMask(room)); + scheduleRoomHide(room); if (room.doneWithScramble()) { logger.debug('everyone done, sending new scramble'); @@ -657,6 +705,7 @@ module.exports = (io, middlewares) => { ns().in(room.accessCode).emit(Protocol.UPDATE_ROOM, joinRoomMask(room)); ns().emit(Protocol.GLOBAL_ROOM_UPDATED, roomMask(room)); + scheduleRoomHide(room); if (room.doneWithScramble()) { logger.debug('everyone done, sending new scramble');