@@ -231,12 +306,23 @@ Main.propTypes = {
waitingFor: PropTypes.shape(),
statuses: PropTypes.shape(),
attempts: PropTypes.arrayOf(PropTypes.shape({
+ _id: PropTypes.string,
id: PropTypes.number,
})),
admin: PropTypes.shape({
id: PropTypes.number,
}),
timerFocused: PropTypes.bool,
+ resultSubmission: PropTypes.shape({
+ status: PropTypes.oneOf(['idle', 'pending', 'sending', 'failed']),
+ pendingResult: PropTypes.shape({
+ deliveryAttempted: PropTypes.bool,
+ submissionId: PropTypes.string,
+ }),
+ error: PropTypes.shape({
+ message: PropTypes.string,
+ }),
+ }),
}),
user: PropTypes.shape({
id: PropTypes.number,
@@ -247,6 +333,7 @@ Main.propTypes = {
dispatch: PropTypes.func.isRequired,
classes: PropTypes.shape().isRequired,
onlyShowSelf: PropTypes.bool,
+ roomConnected: PropTypes.bool,
};
Main.defaultProps = {
@@ -264,6 +351,11 @@ Main.defaultProps = {
id: undefined,
},
timerFocused: true,
+ resultSubmission: {
+ status: 'idle',
+ pendingResult: null,
+ error: null,
+ },
},
user: {
id: undefined,
@@ -272,10 +364,12 @@ Main.defaultProps = {
timerType: 'spacebar',
},
onlyShowSelf: false,
+ roomConnected: false,
};
const mapStateToProps = (state) => ({
room: state.room,
+ roomConnected: state.roomList.connected,
user: state.user,
});
diff --git a/client/src/components/Room/Common/Main.test.jsx b/client/src/components/Room/Common/Main.test.jsx
new file mode 100644
index 0000000..4f28d8b
--- /dev/null
+++ b/client/src/components/Room/Common/Main.test.jsx
@@ -0,0 +1,180 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import Alert from '@material-ui/lab/Alert';
+import Button from '@material-ui/core/Button';
+import Timer from '../../Timer/index';
+import { Main } from './Main';
+import {
+ createPendingResult,
+ markPendingResultAttempted,
+} from '../../../store/room/resultOutbox';
+import {
+ DISCARD_PENDING_RESULT,
+ SUBMIT_RESULT,
+} from '../../../store/room/actions';
+
+jest.mock('../../Timer/StackmatTimer', () => () => null);
+jest.mock('react-cube-svg', () => ({ Cube: () => null }));
+
+const pendingResult = createPendingResult({
+ userId: 42,
+ roomId: 'original-room',
+ attemptId: 7,
+ attemptKey: 'attempt-seven',
+ result: { time: 1234, penalties: {} },
+}, {
+ createId: () => 'submission-one',
+ now: () => 1000,
+});
+
+const makeProps = (overrides = {}) => ({
+ classes: {
+ root: 'root',
+ submissionAlert: 'submissionAlert',
+ scrambleBox: 'scrambleBox',
+ waitingForBox: 'waitingForBox',
+ },
+ dispatch: jest.fn(),
+ onlyShowSelf: false,
+ roomConnected: true,
+ user: {
+ id: 42,
+ useInspection: false,
+ muteTimer: true,
+ timerType: 'spacebar',
+ },
+ room: {
+ _id: 'current-room',
+ event: '333',
+ users: [{ id: 42 }],
+ competing: { 42: true },
+ waitingFor: { 42: true },
+ attempts: [{ _id: 'attempt-eight', id: 8, scrambles: ['R U'], results: {} }],
+ timerFocused: true,
+ resultSubmission: {
+ status: 'pending',
+ pendingResult,
+ error: null,
+ },
+ },
+ ...overrides,
+});
+
+describe('room pending result UX', () => {
+ it('submits against the immutable attempt that was primed', () => {
+ const props = makeProps({
+ room: {
+ ...makeProps().room,
+ attempts: [{
+ _id: 'primed-attempt', id: 0, scrambles: ['R U'], results: {},
+ }],
+ resultSubmission: {
+ status: 'idle',
+ pendingResult: null,
+ error: null,
+ },
+ },
+ });
+ const wrapper = shallow(
);
+ wrapper.instance().handlePriming();
+ wrapper.setProps({
+ room: {
+ ...props.room,
+ attempts: [{
+ _id: 'replacement-attempt', id: 0, scrambles: ['F R'], results: {},
+ }],
+ },
+ });
+
+ wrapper.instance().onSubmitTime({ time: 1234, penalties: {} });
+
+ expect(props.dispatch).toHaveBeenCalledWith({
+ type: SUBMIT_RESULT,
+ result: {
+ id: 0,
+ attemptKey: 'primed-attempt',
+ result: { time: 1234, penalties: {} },
+ },
+ });
+ });
+
+ it('does not disable the timer merely because the room socket disconnected', () => {
+ const props = makeProps({
+ roomConnected: false,
+ room: {
+ ...makeProps().room,
+ resultSubmission: {
+ status: 'idle',
+ pendingResult: null,
+ error: null,
+ },
+ },
+ });
+ const wrapper = shallow(
);
+
+ expect(wrapper.find(Timer).prop('disabled')).toBe(false);
+ expect(wrapper.find(Alert)).toHaveLength(0);
+ });
+
+ it('blocks another solve and offers return/discard actions outside the original room', () => {
+ const props = makeProps();
+ const wrapper = shallow(
);
+ const actions = shallow(
{wrapper.find(Alert).prop('action')}
);
+
+ expect(wrapper.find(Timer).prop('disabled')).toBe(true);
+ expect(wrapper.find(Alert).text()).toContain('Your saved time belongs to room original-room.');
+ expect(actions.find(Button).map((button) => button.text())).toEqual([
+ 'Return to room',
+ 'Discard saved result',
+ ]);
+
+ actions.find(Button).at(1).simulate('click');
+ expect(props.dispatch).toHaveBeenCalledWith({
+ type: DISCARD_PENDING_RESULT,
+ submissionId: 'submission-one',
+ });
+ });
+
+ it('also blocks a solve when the device outbox belongs to another account', () => {
+ const anotherUsersResult = {
+ ...pendingResult,
+ userId: 99,
+ };
+ const props = makeProps({
+ room: {
+ ...makeProps().room,
+ resultSubmission: {
+ status: 'pending',
+ pendingResult: anotherUsersResult,
+ error: null,
+ },
+ },
+ });
+ const wrapper = shallow(
);
+ const actions = shallow(
{wrapper.find(Alert).prop('action')}
);
+
+ expect(wrapper.find(Timer).prop('disabled')).toBe(true);
+ expect(wrapper.find(Alert).text()).toContain('another account');
+ expect(actions.find(Button)).toHaveLength(1);
+ expect(actions.find(Button).text()).toBe('Discard saved result');
+ });
+
+ it('keeps an attempted result without offering discard', () => {
+ const props = makeProps({
+ room: {
+ ...makeProps().room,
+ resultSubmission: {
+ status: 'pending',
+ pendingResult: markPendingResultAttempted(pendingResult),
+ error: null,
+ },
+ },
+ });
+ const wrapper = shallow(
);
+ const actions = shallow(
{wrapper.find(Alert).prop('action')}
);
+
+ expect(actions.find(Button)).toHaveLength(1);
+ expect(actions.find(Button).text()).toBe('Return to room');
+ expect(wrapper.find(Alert).text()).toContain('finish submitting');
+ });
+});
diff --git a/client/src/components/Room/GrandPrix/GrandPrixMain.jsx b/client/src/components/Room/GrandPrix/GrandPrixMain.jsx
index 7bc2331..1a656ac 100644
--- a/client/src/components/Room/GrandPrix/GrandPrixMain.jsx
+++ b/client/src/components/Room/GrandPrix/GrandPrixMain.jsx
@@ -19,6 +19,7 @@ import {
toggleFollowUser,
} from '../../../store/room/actions';
import { getRegisteredUsers } from '../../../store/room/selectors';
+import { isPendingResult } from '../../../store/room/resultOutbox';
import { StatsDialogProvider } from '../Common/StatsDialogProvider';
import { EditDialogProvider } from '../Common/EditDialogProvider';
import TimesTable from '../Common/TimesTable';
@@ -184,7 +185,7 @@ function Main({ room, user }) {
const classes = useStyles();
const dispatch = useDispatch();
const theme = useTheme();
- const [currentAttemptId, setCurrentAttemptId] = useState(undefined);
+ const [currentAttempt, setCurrentAttempt] = useState(undefined);
const [followUserDialogOpen, setFollowUserDialogOpen] = useState(false);
const {
@@ -204,14 +205,16 @@ function Main({ room, user }) {
}
const latestAttempt = room.attempts ? room.attempts[room.attempts.length - 1] : {};
+ const submittedAttempt = currentAttempt || latestAttempt;
dispatch(submitResult({
- id: currentAttemptId || latestAttempt.id,
+ id: submittedAttempt.id,
+ attemptKey: submittedAttempt._id,
result: {
time: event.time,
penalties: event.penalties,
},
}));
- setCurrentAttemptId(null);
+ setCurrentAttempt(null);
};
const onTimerFocused = () => {
@@ -228,7 +231,7 @@ function Main({ room, user }) {
const handlePriming = () => {
const latestAttempt = room.attempts ? room.attempts[room.attempts.length - 1] : {};
- setCurrentAttemptId(latestAttempt.id);
+ setCurrentAttempt(latestAttempt);
};
const handleToggleUserFollow = (userId) => {
@@ -237,7 +240,8 @@ function Main({ room, user }) {
const latestAttempt = attempts && attempts.length && attempts[attempts.length - 1];
const showScrambleBox = latestAttempt && !latestAttempt.results[user.id];
- const timerDisabled = !room.timerFocused || !room.competing[user.id] || !showScrambleBox;
+ const timerDisabled = !room.timerFocused || !room.competing[user.id] || !showScrambleBox
+ || isPendingResult(room.resultSubmission && room.resultSubmission.pendingResult);
const stats = calcStats(attempts, users);
const showScramble = latestAttempt.scrambles && room.event === '333';
@@ -369,8 +373,14 @@ Main.propTypes = {
statuses: PropTypes.shape(),
registered: PropTypes.shape(),
attempts: PropTypes.arrayOf(PropTypes.shape({
+ _id: PropTypes.string,
id: PropTypes.number,
})),
+ resultSubmission: PropTypes.shape({
+ pendingResult: PropTypes.shape({
+ submissionId: PropTypes.string,
+ }),
+ }),
admin: PropTypes.shape({
id: PropTypes.number,
}),
@@ -399,6 +409,9 @@ Main.defaultProps = {
statues: {},
registered: {},
attempts: [],
+ resultSubmission: {
+ pendingResult: null,
+ },
admin: {
id: undefined,
},
diff --git a/client/src/components/Timer/ManualTimer.jsx b/client/src/components/Timer/ManualTimer.jsx
index f9cef7a..4b69a6d 100644
--- a/client/src/components/Timer/ManualTimer.jsx
+++ b/client/src/components/Timer/ManualTimer.jsx
@@ -60,7 +60,7 @@ const STATUS = {
const STATUSES = [STATUS.RESTING, STATUS.INSPECTING, STATUS.TIMING, STATUS.SUBMITTING];
-class ManualTimer extends React.Component {
+export class ManualTimer extends React.Component {
constructor(props) {
super(props);
@@ -194,10 +194,15 @@ class ManualTimer extends React.Component {
const { onSubmitTime } = this.props;
const { penalties } = this.state;
+ const time = this.finalTime();
+
+ if (!Number.isFinite(time)) {
+ return;
+ }
if (onSubmitTime) {
onSubmitTime({
- time: this.finalTime(),
+ time,
penalties: {
DNF: penalties.DNF,
inspection: penalties.inspection,
diff --git a/client/src/components/Timer/ManualTimer.test.jsx b/client/src/components/Timer/ManualTimer.test.jsx
new file mode 100644
index 0000000..a149cb2
--- /dev/null
+++ b/client/src/components/Timer/ManualTimer.test.jsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { ManualTimer } from './ManualTimer';
+
+const makeProps = () => ({
+ classes: {
+ root: 'root',
+ input: 'input',
+ inputProps: 'inputProps',
+ penaltyBox: 'penaltyBox',
+ },
+ onPriming: jest.fn(),
+ onStatusChange: jest.fn(),
+ onSubmitTime: jest.fn(),
+ useInspection: false,
+});
+
+describe('manual timer submission', () => {
+ it.each(['', 'not-a-time'])('keeps invalid input for correction: %p', (timeInput) => {
+ const props = makeProps();
+ const wrapper = shallow(
);
+ wrapper.setState({ timeInput });
+
+ wrapper.instance().submitTime({ preventDefault: jest.fn() });
+
+ expect(props.onSubmitTime).not.toHaveBeenCalled();
+ expect(wrapper.state('timeInput')).toBe(timeInput);
+ });
+
+ it('submits and resets a valid time', () => {
+ const props = makeProps();
+ const wrapper = shallow(
);
+ wrapper.setState({ timeInput: '5.00' });
+
+ wrapper.instance().submitTime({ preventDefault: jest.fn() });
+
+ expect(props.onSubmitTime).toHaveBeenCalledWith({
+ time: 5000,
+ penalties: {
+ AUF: false,
+ DNF: false,
+ inspection: false,
+ },
+ });
+ expect(wrapper.state('timeInput')).toBe('');
+ });
+});
diff --git a/client/src/components/Timer/StackmatTimer.jsx b/client/src/components/Timer/StackmatTimer.jsx
index 039ec65..e1b2045 100644
--- a/client/src/components/Timer/StackmatTimer.jsx
+++ b/client/src/components/Timer/StackmatTimer.jsx
@@ -228,6 +228,10 @@ class Timer extends React.Component {
setStatus (status) {
this.setState({ status });
+ if (status === STATUS.TIMING) {
+ this.props.onPriming();
+ }
+
if (STATUSES.indexOf(status) > -1) {
this.props.onStatusChange(status);
}
diff --git a/client/src/lib/Namespace.js b/client/src/lib/Namespace.js
index bb2058e..e82eb79 100644
--- a/client/src/lib/Namespace.js
+++ b/client/src/lib/Namespace.js
@@ -1,5 +1,5 @@
// import io from 'socket.io-client';
-import * as Protocol from './protocol';
+import Protocol from './protocol';
Error.stackTraceLimit = Infinity;
diff --git a/client/src/lib/events.js b/client/src/lib/events.js
index 1f016a5..79bcb7f 100644
--- a/client/src/lib/events.js
+++ b/client/src/lib/events.js
@@ -1,145 +1,5 @@
-const Events = [
- {
- id: '333',
- scrambler: '333',
- name: '3x3',
- group: 'WCA',
- },
- {
- id: '222',
- scrambler: '222',
- name: '2x2',
- group: 'WCA',
- },
- {
- id: '444',
- scrambler: '444',
- name: '4x4',
- group: 'WCA',
- },
- {
- id: '555',
- scrambler: '555',
- name: '5x5',
- group: 'WCA',
- },
- {
- id: '666',
- scrambler: '666',
- name: '6x6',
- group: 'WCA',
- },
- {
- id: '777',
- scrambler: '777',
- name: '7x7',
- group: 'WCA',
- },
- {
- id: '333bf',
- scrambler: '333',
- name: '3x3 Blindfolded',
- group: 'WCA',
- // }, {
- // id: '333fm',
- // scrambler: '333',
- // name: '3x3x3 Fewest Moves',
- },
- {
- id: '333oh',
- scrambler: '333',
- name: '3x3 One-Handed',
- group: 'WCA',
- },
- {
- id: '333ft',
- scrambler: '333',
- name: '3x3 With Feet',
- group: 'WCA',
- },
- {
- id: 'minx',
- scrambler: 'minx',
- name: 'Megaminx',
- group: 'WCA',
- },
- {
- id: 'pyram',
- scrambler: 'pyram',
- name: 'Pyraminx',
- group: 'WCA',
- },
- {
- id: 'clock',
- scrambler: 'clock',
- name: 'Clock',
- group: 'WCA',
- },
- {
- id: 'clock-optimal',
- scrambler: 'clock-optimal',
- name: 'Clock (Optimal)',
- group: 'Other',
- },
- {
- id: 'skewb',
- scrambler: 'skewb',
- name: 'Skewb',
- group: 'WCA',
- },
- {
- id: 'sq1',
- scrambler: 'sq1',
- name: 'Square-1',
- group: 'WCA',
- },
- {
- id: '444bf',
- scrambler: '444',
- name: '4x4 Blindfolded',
- group: 'WCA',
- },
- {
- id: '555bf',
- scrambler: '555',
- name: '5x5 Blindfolded',
- group: 'WCA',
- // }, {
- // id: '333mbf',
- // name: '3x3x3 Multi-Blind',
- },
- {
- id: 'fto',
- scrambler: 'fto',
- name: 'FTO',
- group: 'Other',
- },
- {
- id: 'pll',
- scrambler: 'pll',
- name: 'PLL',
- group: 'Other',
- },
- {
- id: 'zbll',
- scrambler: 'zbll',
- name: 'ZBLL',
- group: 'Other',
- },
- {
- id: 'lse',
- scrambler: 'lse',
- name: 'Last Six Edges',
- group: 'Other',
- },
- {
- id: 'ru',
- scrambler: 'ru',
- name: 'RU 2gen',
- group: 'Other',
- },
-];
+import Events from './events.json';
-module.exports.Events = Events;
+export { Events };
-module.exports.getNameFromId = (eventId) => Events.find((e) => e.id === eventId).name;
+export const getNameFromId = (eventId) => Events.find((event) => event.id === eventId).name;
diff --git a/client/src/lib/events.json b/client/src/lib/events.json
new file mode 100644
index 0000000..78cbcf5
--- /dev/null
+++ b/client/src/lib/events.json
@@ -0,0 +1,134 @@
+[
+ {
+ "id": "333",
+ "scrambler": "333",
+ "name": "3x3",
+ "group": "WCA"
+ },
+ {
+ "id": "222",
+ "scrambler": "222",
+ "name": "2x2",
+ "group": "WCA"
+ },
+ {
+ "id": "444",
+ "scrambler": "444",
+ "name": "4x4",
+ "group": "WCA"
+ },
+ {
+ "id": "555",
+ "scrambler": "555",
+ "name": "5x5",
+ "group": "WCA"
+ },
+ {
+ "id": "666",
+ "scrambler": "666",
+ "name": "6x6",
+ "group": "WCA"
+ },
+ {
+ "id": "777",
+ "scrambler": "777",
+ "name": "7x7",
+ "group": "WCA"
+ },
+ {
+ "id": "333bf",
+ "scrambler": "333",
+ "name": "3x3 Blindfolded",
+ "group": "WCA"
+ },
+ {
+ "id": "333oh",
+ "scrambler": "333",
+ "name": "3x3 One-Handed",
+ "group": "WCA"
+ },
+ {
+ "id": "333ft",
+ "scrambler": "333",
+ "name": "3x3 With Feet",
+ "group": "WCA"
+ },
+ {
+ "id": "minx",
+ "scrambler": "minx",
+ "name": "Megaminx",
+ "group": "WCA"
+ },
+ {
+ "id": "pyram",
+ "scrambler": "pyram",
+ "name": "Pyraminx",
+ "group": "WCA"
+ },
+ {
+ "id": "clock",
+ "scrambler": "clock",
+ "name": "Clock",
+ "group": "WCA"
+ },
+ {
+ "id": "clock-optimal",
+ "scrambler": "clock-optimal",
+ "name": "Clock (Optimal)",
+ "group": "Other"
+ },
+ {
+ "id": "skewb",
+ "scrambler": "skewb",
+ "name": "Skewb",
+ "group": "WCA"
+ },
+ {
+ "id": "sq1",
+ "scrambler": "sq1",
+ "name": "Square-1",
+ "group": "WCA"
+ },
+ {
+ "id": "444bf",
+ "scrambler": "444",
+ "name": "4x4 Blindfolded",
+ "group": "WCA"
+ },
+ {
+ "id": "555bf",
+ "scrambler": "555",
+ "name": "5x5 Blindfolded",
+ "group": "WCA"
+ },
+ {
+ "id": "fto",
+ "scrambler": "fto",
+ "name": "FTO",
+ "group": "Other"
+ },
+ {
+ "id": "pll",
+ "scrambler": "pll",
+ "name": "PLL",
+ "group": "Other"
+ },
+ {
+ "id": "zbll",
+ "scrambler": "zbll",
+ "name": "ZBLL",
+ "group": "Other"
+ },
+ {
+ "id": "lse",
+ "scrambler": "lse",
+ "name": "Last Six Edges",
+ "group": "Other"
+ },
+ {
+ "id": "ru",
+ "scrambler": "ru",
+ "name": "RU 2gen",
+ "group": "Other"
+ }
+]
diff --git a/client/src/lib/protocol.js b/client/src/lib/protocol.js
index 29b44ae..b224a35 100644
--- a/client/src/lib/protocol.js
+++ b/client/src/lib/protocol.js
@@ -1,49 +1,3 @@
-// socket.io event names
-module.exports = {
- MESSAGE: 'message',
- CONNECT: 'connect',
- DISCONNECT: 'disconnect',
- CONNECT_ERR: 'connect_error',
- RECONNECT_ERR: 'reconnect_error',
- RECONNECT: 'reconnect',
- ERROR: 'errorrr',
- UPDATE_ROOMS: 'update_rooms',
- UPDATE_ROOM: 'update_room',
- GLOBAL_ROOM_UPDATED: 'global_room_updated',
- CREATE_ROOM: 'create_room',
- DELETE_ROOM: 'delete_room',
- ROOM_CREATED: 'room_created',
- ROOM_DELETED: 'room_deleted',
- UPDATE_ADMIN: 'update_admin',
- FORCE_JOIN: 'force_join',
- FORCE_LEAVE: 'force_leave',
- KICKED: 'kicked',
- BANNED: 'banned',
- JOIN_ROOM: 'join_room',
- LEAVE_ROOM: 'leave_room',
- JOIN: 'join',
- USER_JOIN: 'user_join',
- USER_LEFT: 'user_left',
- SUBMIT_RESULT: 'submit_result',
- NEW_RESULT: 'new_result',
- SEND_EDIT_RESULT: 'send_edit_result',
- EDIT_RESULT: 'edit_result',
- NEW_ATTEMPT: 'new_attempt',
- REQUEST_SCRAMBLE: 'request_scramble',
- CHANGE_EVENT: 'change_event',
- EDIT_ROOM: 'edit_room',
- UPDATE_PREFERENCES: 'update_preferences',
- UPDATE_STATUS: 'update_status',
- UPDATE_COMPETING: 'update_competing',
- UPDATE_USER_COUNT: 'user_count',
- UPDATE_USERS_IN_LOBBY: 'update_users_in_lobby',
- KICK_USER: 'kick_user',
- BAN_USER: 'ban_user',
- UNBAN_USER: 'unban_user',
- UPDATE_REGISTRATION: 'register',
- NEXT_SOLVE_AT: 'next_solve_at', // transmits an approx time to expect the new scramble
- START_ROOM: 'start',
- PAUSE_ROOM: 'pause',
- UPDATE_USER: 'update_user',
- ADMIN: 'admin',
-};
+import Protocol from './protocol.json';
+
+export default Protocol;
diff --git a/client/src/lib/protocol.json b/client/src/lib/protocol.json
new file mode 100644
index 0000000..563ae31
--- /dev/null
+++ b/client/src/lib/protocol.json
@@ -0,0 +1,50 @@
+{
+ "MESSAGE": "message",
+ "CONNECT": "connect",
+ "DISCONNECT": "disconnect",
+ "CONNECT_ERR": "connect_error",
+ "RECONNECT_ERR": "reconnect_error",
+ "RECONNECT": "reconnect",
+ "HEALTH_CHECK": "health_check",
+ "HEALTH_STATUS": "health_status",
+ "ERROR": "errorrr",
+ "UPDATE_ROOMS": "update_rooms",
+ "UPDATE_ROOM": "update_room",
+ "GLOBAL_ROOM_UPDATED": "global_room_updated",
+ "CREATE_ROOM": "create_room",
+ "DELETE_ROOM": "delete_room",
+ "ROOM_CREATED": "room_created",
+ "ROOM_DELETED": "room_deleted",
+ "UPDATE_ADMIN": "update_admin",
+ "FORCE_JOIN": "force_join",
+ "FORCE_LEAVE": "force_leave",
+ "KICKED": "kicked",
+ "BANNED": "banned",
+ "JOIN_ROOM": "join_room",
+ "LEAVE_ROOM": "leave_room",
+ "JOIN": "join",
+ "USER_JOIN": "user_join",
+ "USER_LEFT": "user_left",
+ "SUBMIT_RESULT": "submit_result",
+ "NEW_RESULT": "new_result",
+ "SEND_EDIT_RESULT": "send_edit_result",
+ "EDIT_RESULT": "edit_result",
+ "NEW_ATTEMPT": "new_attempt",
+ "REQUEST_SCRAMBLE": "request_scramble",
+ "CHANGE_EVENT": "change_event",
+ "EDIT_ROOM": "edit_room",
+ "UPDATE_PREFERENCES": "update_preferences",
+ "UPDATE_STATUS": "update_status",
+ "UPDATE_COMPETING": "update_competing",
+ "UPDATE_USER_COUNT": "user_count",
+ "UPDATE_USERS_IN_LOBBY": "update_users_in_lobby",
+ "KICK_USER": "kick_user",
+ "BAN_USER": "ban_user",
+ "UNBAN_USER": "unban_user",
+ "UPDATE_REGISTRATION": "register",
+ "NEXT_SOLVE_AT": "next_solve_at",
+ "START_ROOM": "start",
+ "PAUSE_ROOM": "pause",
+ "UPDATE_USER": "update_user",
+ "ADMIN": "admin"
+}
diff --git a/client/src/store/index.js b/client/src/store/index.js
index 5284e02..5f4bcee 100644
--- a/client/src/store/index.js
+++ b/client/src/store/index.js
@@ -33,7 +33,8 @@ const trackPage = (page) => {
const gaTrackingMiddleware = () => (next) => (action) => {
if (action.type === '@@router/LOCATION_CHANGE') {
- const nextPage = `${action.payload.location.pathname}${action.payload.location.search}`;
+ // Query strings can contain the temporary WCA OAuth authorization code.
+ const nextPage = action.payload.location.pathname;
trackPage(nextPage);
}
return next(action);
diff --git a/client/src/store/middlewares/default.js b/client/src/store/middlewares/default.js
index ae4d7c7..366c97c 100644
--- a/client/src/store/middlewares/default.js
+++ b/client/src/store/middlewares/default.js
@@ -1,4 +1,4 @@
-import * as Protocol from '../../lib/protocol';
+import Protocol from '../../lib/protocol';
import Namespace from '../../lib/Namespace';
import {
CONNECT,
diff --git a/client/src/store/middlewares/rooms.js b/client/src/store/middlewares/rooms.js
index 7155296..f65f321 100644
--- a/client/src/store/middlewares/rooms.js
+++ b/client/src/store/middlewares/rooms.js
@@ -1,7 +1,7 @@
import { push } from 'connected-react-router';
import UIfx from 'uifx';
import notificationAsset from '../../assets/notification.mp3';
-import * as Protocol from '../../lib/protocol';
+import Protocol from '../../lib/protocol';
import Namespace from '../../lib/Namespace';
import { uuid } from '../../lib/utils';
import {
@@ -21,8 +21,8 @@ import {
START_ROOM,
PAUSE_ROOM,
UPDATE_USER,
+ DISCARD_PENDING_RESULT,
joinRoom,
- // leaveRoom,
roomUpdated,
resetRoom,
userJoined,
@@ -34,7 +34,21 @@ import {
updateAdmin,
updateCompetingForUser,
nextSolveAt,
+ resultSubmissionPending,
+ resultSubmissionSending,
+ resultSubmissionFailed,
+ resultSubmissionCleared,
} from '../room/actions';
+import {
+ canDiscardPendingResult,
+ clearPendingResult,
+ createPendingResult,
+ markPendingResultAttempted,
+ markPendingResultFailed,
+ pendingResultMatches,
+ persistPendingResult,
+ readPendingResult,
+} from '../room/resultOutbox';
import {
ROOMS_CONNECT,
ROOMS_DISCONNECT,
@@ -57,29 +71,273 @@ import {
} from '../admin/actions';
import { manager } from './manager';
-const roomsNamespaceMiddleware = (store) => {
+const DEFAULT_ACK_TIMEOUT_MS = 10000;
+const DEFAULT_RETRY_DELAY_MS = 2000;
+
+export const createRoomsNamespaceMiddleware = ({
+ NamespaceClass = Namespace,
+ namespaceManager = manager,
+ storage = window.localStorage,
+ createSubmissionId = uuid,
+ now = Date.now,
+ setTimeoutFn = window.setTimeout.bind(window),
+ clearTimeoutFn = window.clearTimeout.bind(window),
+ ackTimeoutMs = DEFAULT_ACK_TIMEOUT_MS,
+ retryDelayMs = DEFAULT_RETRY_DELAY_MS,
+} = {}) => (store) => {
+ let roomsConnected = false;
+ let joinedRoomId = null;
+ let pendingJoinRequest = null;
+ let joinGeneration = 0;
+ let submissionGeneration = 0;
+ let submissionAckTimer = null;
+ let submissionRetryTimer = null;
+ let hydratedPendingResult = false;
+ let storageWarningSubmissionId = null;
+
+ const currentSubmission = () => store.getState().room.resultSubmission;
+ const readStoredPendingResult = () => {
+ try {
+ return readPendingResult(storage);
+ } catch (storageError) {
+ return null;
+ }
+ };
+
+ const warnPendingResultNotBackedUp = (submissionId) => {
+ if (storageWarningSubmissionId === submissionId) {
+ return;
+ }
+
+ storageWarningSubmissionId = submissionId;
+ store.dispatch(createMessage({
+ severity: 'warning',
+ text: 'This result could not be backed up on this device. Keep this tab open while it submits.',
+ }));
+ };
+
+ const clearSubmissionTimers = () => {
+ if (submissionAckTimer !== null) {
+ clearTimeoutFn(submissionAckTimer);
+ submissionAckTimer = null;
+ }
+ if (submissionRetryTimer !== null) {
+ clearTimeoutFn(submissionRetryTimer);
+ submissionRetryTimer = null;
+ }
+ };
+
+ const invalidateSubmissionAttempt = () => {
+ submissionGeneration += 1;
+ clearSubmissionTimers();
+ };
+
+ const normalizeSubmissionError = (error, fallbackMessage) => ({
+ statusCode: error && error.statusCode,
+ message: (error && error.message) || fallbackMessage,
+ retryable: !!(error && error.retryable),
+ });
+
+ const normalizeResultForComparison = (result = {}) => ({
+ time: result.time,
+ AUF: !!(result.penalties && result.penalties.AUF),
+ DNF: !!(result.penalties && result.penalties.DNF),
+ inspection: !!(result.penalties && result.penalties.inspection),
+ });
+
+ const resultEchoMatches = (echo, pendingResult) => {
+ const { room, user } = store.getState();
+ const echoedSubmissionId = echo.result && echo.result.submissionId;
+ const pendingValue = normalizeResultForComparison(pendingResult.result);
+ const echoedValue = normalizeResultForComparison(echo.result);
+
+ return pendingResultMatches(pendingResult, { userId: user.id, roomId: room._id })
+ && String(joinedRoomId) === String(room._id)
+ && String(echo.userId) === String(user.id)
+ && String(echo.id) === String(pendingResult.attemptId)
+ && echoedSubmissionId === undefined
+ && pendingValue.time === echoedValue.time
+ && pendingValue.AUF === echoedValue.AUF
+ && pendingValue.DNF === echoedValue.DNF
+ && pendingValue.inspection === echoedValue.inspection;
+ };
+
+ let flushPendingResult;
+
+ const completePendingResult = (pendingResult) => {
+ const submission = currentSubmission();
+ if (!submission.pendingResult
+ || submission.pendingResult.submissionId !== pendingResult.submissionId) {
+ return false;
+ }
+
+ invalidateSubmissionAttempt();
+ try {
+ const cleared = clearPendingResult(pendingResult.submissionId, storage);
+ if (!cleared) {
+ const storedPendingResult = readStoredPendingResult();
+ if (storedPendingResult) {
+ store.dispatch(resultSubmissionPending(storedPendingResult));
+ flushPendingResult();
+ return false;
+ }
+ }
+ } catch (storageError) {
+ store.dispatch(createMessage({
+ severity: 'warning',
+ text: 'Your result was saved, but its local backup could not be removed.',
+ }));
+ }
+ store.dispatch(resultSubmissionCleared(pendingResult.submissionId));
+ return true;
+ };
+
+ let namespace;
+
+ flushPendingResult = () => {
+ const submission = currentSubmission();
+ let pendingResult = submission && submission.pendingResult;
+ const { room, user } = store.getState();
+
+ if (!pendingResult
+ || submission.status === 'sending'
+ || submission.status === 'failed'
+ || !roomsConnected
+ || String(joinedRoomId) !== String(room._id)
+ || !pendingResultMatches(pendingResult, { userId: user.id, roomId: room._id })) {
+ return;
+ }
+
+ if (submissionRetryTimer !== null) {
+ clearTimeoutFn(submissionRetryTimer);
+ submissionRetryTimer = null;
+ }
+
+ if (pendingResult.deliveryAttempted !== true) {
+ pendingResult = markPendingResultAttempted(pendingResult);
+ store.dispatch(resultSubmissionPending(pendingResult));
+ try {
+ persistPendingResult(pendingResult, storage);
+ } catch (storageError) {
+ warnPendingResultNotBackedUp(pendingResult.submissionId);
+ }
+ }
+
+ const generation = submissionGeneration + 1;
+ submissionGeneration = generation;
+ store.dispatch(resultSubmissionSending(pendingResult.submissionId));
+
+ const retrySubmission = () => {
+ if (generation !== submissionGeneration) {
+ return;
+ }
+
+ invalidateSubmissionAttempt();
+ store.dispatch(resultSubmissionPending(pendingResult));
+ submissionRetryTimer = setTimeoutFn(() => {
+ submissionRetryTimer = null;
+ flushPendingResult();
+ }, retryDelayMs);
+ };
+
+ submissionAckTimer = setTimeoutFn(() => {
+ submissionAckTimer = null;
+ retrySubmission();
+ }, ackTimeoutMs);
+
+ namespace.emit(Protocol.SUBMIT_RESULT, {
+ id: pendingResult.attemptId,
+ attemptKey: pendingResult.attemptKey,
+ result: pendingResult.result,
+ submissionId: pendingResult.submissionId,
+ }, (error, receipt) => {
+ if (generation !== submissionGeneration) {
+ return;
+ }
+
+ if (submissionAckTimer !== null) {
+ clearTimeoutFn(submissionAckTimer);
+ submissionAckTimer = null;
+ }
+
+ if (error) {
+ if (error.retryable) {
+ retrySubmission();
+ return;
+ }
+
+ invalidateSubmissionAttempt();
+ const submissionError = normalizeSubmissionError(
+ error,
+ 'The server rejected this result.',
+ );
+ pendingResult = markPendingResultFailed(pendingResult, submissionError);
+ try {
+ persistPendingResult(pendingResult, storage);
+ } catch (storageError) {
+ warnPendingResultNotBackedUp(pendingResult.submissionId);
+ }
+ store.dispatch(resultSubmissionPending(pendingResult));
+ store.dispatch(resultSubmissionFailed(pendingResult.submissionId, submissionError));
+ return;
+ }
+
+ const accepted = receipt
+ && receipt.submissionId === pendingResult.submissionId
+ && (receipt.status === 'saved' || receipt.status === 'duplicate');
+
+ if (!accepted) {
+ retrySubmission();
+ return;
+ }
+
+ completePendingResult(pendingResult);
+ });
+ };
+
const reconnectToRoom = () => {
- if (store.getState().room.accessCode) {
+ const { room } = store.getState();
+ const request = pendingJoinRequest || (room.accessCode ? {
+ id: room._id,
+ password: room.password,
+ } : null);
+
+ if (request) {
store.dispatch(joinRoom({
- id: store.getState().room._id,
- password: store.getState().room.password,
+ ...request,
+ reconnecting: true,
}));
}
};
- const namespace = new Namespace({
- manager,
+ const handleDisconnected = () => {
+ roomsConnected = false;
+ joinedRoomId = null;
+ joinGeneration += 1;
+
+ const submission = currentSubmission();
+ const pendingResult = submission && submission.pendingResult;
+ invalidateSubmissionAttempt();
+ if (pendingResult && submission.status === 'sending') {
+ store.dispatch(resultSubmissionPending(pendingResult));
+ }
+
+ store.dispatch(disconnected());
+ };
+
+ namespace = new NamespaceClass({
+ manager: namespaceManager,
namespace: '/rooms',
onChange: (isConnected) => {
store.dispatch(connectionChanged(isConnected));
},
onConnected: () => {
+ roomsConnected = true;
+ joinedRoomId = null;
store.dispatch(connected());
reconnectToRoom();
},
- onDisconnected: () => {
- store.dispatch(disconnected());
- },
+ onDisconnected: handleDisconnected,
events: {
[Protocol.ERROR]: (error) => {
// eslint-disable-next-line no-console
@@ -171,11 +429,15 @@ const roomsNamespaceMiddleware = (store) => {
}
},
[Protocol.NEW_RESULT]: (result) => {
+ const submission = currentSubmission();
+ if (submission.pendingResult
+ && resultEchoMatches(result, submission.pendingResult)) {
+ completePendingResult(submission.pendingResult);
+ }
store.dispatch(newResult(result));
},
[Protocol.EDIT_RESULT]: (result) => {
store.dispatch(editResult(result));
- // calculate grand prix points
},
[Protocol.MESSAGE]: (message) => {
store.dispatch(receiveChat(message));
@@ -203,7 +465,6 @@ const roomsNamespaceMiddleware = (store) => {
},
});
- // catch attempt to join room here and then fetch socket event
const reducers = {
[ROOMS_CONNECT]: () => {
namespace.connect();
@@ -211,17 +472,14 @@ const roomsNamespaceMiddleware = (store) => {
[ROOMS_DISCONNECT]: () => {
namespace.disconnect();
},
- // no real point in this being here oper other places
'@@router/LOCATION_CHANGE': ({ payload }) => {
- // TODO: improve
if (payload.location.pathname === '/' || payload.location.pathname === '/profile') {
document.title = 'Let\'s Cube';
}
},
[USER_CHANGED]: () => {
- // TODO: improve
- manager.disconnect();
- manager.connect();
+ namespaceManager.disconnect();
+ namespaceManager.connect();
},
[DELETE_ROOM]: ({ id }) => {
namespace.emit(Protocol.DELETE_ROOM, id, (err) => {
@@ -234,7 +492,17 @@ const roomsNamespaceMiddleware = (store) => {
});
},
[JOIN_ROOM]: ({ id, password }) => {
+ const generation = joinGeneration + 1;
+ joinGeneration = generation;
+ joinedRoomId = null;
+ pendingJoinRequest = { id, password };
namespace.emit(Protocol.JOIN_ROOM, { id, password }, (err, room) => {
+ if (generation !== joinGeneration) {
+ return;
+ }
+
+ pendingJoinRequest = null;
+
if (err) {
if (err.banned) {
store.dispatch(push('/'));
@@ -244,10 +512,13 @@ const roomsNamespaceMiddleware = (store) => {
severity: 'error',
text: err.message,
}));
+ return;
}
if (room) {
store.dispatch(roomUpdated(room));
+ joinedRoomId = room._id || id;
+ flushPendingResult();
if (room.accessCode) {
store.dispatch(createMessage({
@@ -268,18 +539,93 @@ const roomsNamespaceMiddleware = (store) => {
return;
}
- // store.dispatch(roomJoined(room.accessCode));
store.dispatch(roomUpdated(room));
+ joinedRoomId = room._id;
store.dispatch(push(`/rooms/${room._id}`));
+ flushPendingResult();
});
},
[LEAVE_ROOM]: () => {
+ joinedRoomId = null;
+ pendingJoinRequest = null;
+ joinGeneration += 1;
if (store.getState().room._id) {
namespace.emit(Protocol.LEAVE_ROOM);
}
},
[SUBMIT_RESULT]: (event) => {
- namespace.emit(Protocol.SUBMIT_RESULT, event.result);
+ const submission = currentSubmission();
+ const storedPendingResult = readStoredPendingResult();
+
+ if ((submission && submission.pendingResult) || storedPendingResult) {
+ if (!submission.pendingResult && storedPendingResult) {
+ store.dispatch(resultSubmissionPending(storedPendingResult));
+ }
+ store.dispatch(createMessage({
+ severity: 'warning',
+ text: 'A result is already waiting to be submitted.',
+ }));
+ return;
+ }
+
+ const { room, user } = store.getState();
+ let pendingResult;
+ try {
+ pendingResult = createPendingResult({
+ userId: user.id,
+ roomId: room._id,
+ attemptId: event.result.id,
+ attemptKey: event.result.attemptKey,
+ result: event.result.result,
+ }, {
+ createId: createSubmissionId,
+ now,
+ });
+ } catch (error) {
+ store.dispatch(createMessage({
+ severity: 'warning',
+ text: 'This result is invalid and was not submitted.',
+ }));
+ return;
+ }
+
+ store.dispatch(resultSubmissionPending(pendingResult));
+ try {
+ persistPendingResult(pendingResult, storage);
+ } catch (storageError) {
+ warnPendingResultNotBackedUp(pendingResult.submissionId);
+ }
+
+ flushPendingResult();
+ },
+ [DISCARD_PENDING_RESULT]: ({ submissionId }) => {
+ const submission = currentSubmission();
+ if (!submission.pendingResult
+ || submission.pendingResult.submissionId !== submissionId) {
+ return;
+ }
+
+ if (!canDiscardPendingResult(submission.pendingResult, submission.status)) {
+ store.dispatch(createMessage({
+ severity: 'warning',
+ text: 'This result may already have reached the server and must finish submitting.',
+ }));
+ return;
+ }
+
+ invalidateSubmissionAttempt();
+ try {
+ if (!clearPendingResult(submissionId, storage)) {
+ throw new Error('A newer result is saved on this device.');
+ }
+ } catch (storageError) {
+ store.dispatch(resultSubmissionFailed(submissionId, {
+ message: storageError.message || 'The saved result could not be discarded.',
+ retryable: false,
+ }));
+ return;
+ }
+ store.dispatch(resultSubmissionCleared(submissionId));
},
[SEND_EDIT_RESULT]: (event) => {
namespace.emit(Protocol.SEND_EDIT_RESULT, event.result);
@@ -334,13 +680,20 @@ const roomsNamespaceMiddleware = (store) => {
},
};
- // Return the handler that will be called for each action dispatched
return (next) => (action) => {
+ if (!hydratedPendingResult) {
+ hydratedPendingResult = true;
+ const pendingResult = readStoredPendingResult();
+ if (pendingResult) {
+ store.dispatch(resultSubmissionPending(pendingResult));
+ }
+ }
+
if (reducers[action.type]) {
reducers[action.type](action);
}
- next(action); // This is a middleware, we still need to call this!
+ next(action);
};
};
-export default roomsNamespaceMiddleware;
+export default createRoomsNamespaceMiddleware();
diff --git a/client/src/store/middlewares/rooms.test.js b/client/src/store/middlewares/rooms.test.js
new file mode 100644
index 0000000..57a03af
--- /dev/null
+++ b/client/src/store/middlewares/rooms.test.js
@@ -0,0 +1,444 @@
+import {
+ applyMiddleware, combineReducers, createStore,
+} from 'redux';
+import Protocol from '../../lib/protocol';
+import roomReducer from '../room/reducer';
+import roomsReducer from '../rooms/reducer';
+import messagesReducer from '../messages/reducers';
+import {
+ discardPendingResult,
+ joinRoom,
+ submitResult,
+} from '../room/actions';
+import { connectSocket } from '../rooms/actions';
+import {
+ PENDING_RESULT_STORAGE_KEY,
+ createPendingResult,
+ markPendingResultAttempted,
+ markPendingResultFailed,
+ persistPendingResult,
+} from '../room/resultOutbox';
+import { createRoomsNamespaceMiddleware } from './rooms';
+
+jest.mock('./manager', () => ({
+ manager: {
+ connect: jest.fn(),
+ disconnect: jest.fn(),
+ },
+}));
+
+class FakeNamespace {
+ constructor(options) {
+ this.options = options;
+ this.emitted = [];
+ FakeNamespace.instance = this;
+ }
+
+ connect() {
+ this.connected = true;
+ }
+
+ disconnect() {
+ this.connected = false;
+ }
+
+ emit(event, ...args) {
+ this.emitted.push({
+ event,
+ args,
+ pendingResultAtEmit: window.localStorage.getItem(PENDING_RESULT_STORAGE_KEY),
+ });
+ }
+}
+
+const makeScheduler = () => {
+ let nextId = 1;
+ const timers = new Map();
+
+ return {
+ setTimeoutFn: (callback, delay) => {
+ const id = nextId;
+ nextId += 1;
+ timers.set(id, { callback, delay });
+ return id;
+ },
+ clearTimeoutFn: (id) => timers.delete(id),
+ runNext: (delay) => {
+ const entry = Array.from(timers.entries()).find(([, timer]) => timer.delay === delay);
+ if (!entry) {
+ throw new Error(`No timer scheduled for ${delay}ms.`);
+ }
+ const [id, timer] = entry;
+ timers.delete(id);
+ timer.callback();
+ },
+ };
+};
+
+const initialRoom = () => ({
+ ...roomReducer(undefined, { type: '@@INIT' }),
+ fetching: false,
+ _id: 'room-one',
+ accessCode: 'ABC123',
+ password: null,
+ users: [{ id: 42, displayName: 'Cuber' }],
+ competing: { 42: true },
+ waitingFor: { 42: true },
+ attempts: [{
+ _id: 'attempt-one', id: 12, scrambles: ['R U'], results: {},
+ }],
+});
+
+const buildStore = ({
+ roomState = initialRoom(),
+ storage = window.localStorage,
+} = {}) => {
+ const scheduler = makeScheduler();
+ const namespaceManager = {
+ connect: jest.fn(),
+ disconnect: jest.fn(),
+ };
+ const middleware = createRoomsNamespaceMiddleware({
+ NamespaceClass: FakeNamespace,
+ namespaceManager,
+ storage,
+ createSubmissionId: () => 'submission-one',
+ now: () => 1000,
+ setTimeoutFn: scheduler.setTimeoutFn,
+ clearTimeoutFn: scheduler.clearTimeoutFn,
+ ackTimeoutMs: 100,
+ retryDelayMs: 25,
+ });
+ const reducer = combineReducers({
+ room: roomReducer,
+ roomList: roomsReducer,
+ messages: messagesReducer,
+ user: (state = { id: 42, muteTimer: true }) => state,
+ chat: (state = {}) => state,
+ admin: (state = {}) => state,
+ });
+ const store = createStore(reducer, {
+ room: roomState,
+ roomList: roomsReducer(undefined, { type: '@@INIT' }),
+ messages: messagesReducer(undefined, { type: '@@INIT' }),
+ user: { id: 42, muteTimer: true },
+ chat: {},
+ admin: {},
+ }, applyMiddleware(middleware));
+
+ return {
+ namespace: FakeNamespace.instance,
+ scheduler,
+ store,
+ };
+};
+
+const emissionsFor = (namespace, event) => (
+ namespace.emitted.filter((emission) => emission.event === event)
+);
+
+const acknowledgeJoin = (namespace) => {
+ const join = emissionsFor(namespace, Protocol.JOIN_ROOM).slice(-1)[0];
+ const room = initialRoom();
+ delete room.resultSubmission;
+ join.args[1](null, room);
+};
+
+const result = {
+ id: 12,
+ attemptKey: 'attempt-one',
+ result: {
+ time: 1234,
+ penalties: {},
+ },
+};
+
+describe('rooms result outbox middleware', () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ });
+
+ it('keeps the loaded room mounted while reconnecting its socket', () => {
+ const { namespace, store } = buildStore();
+ const { attempts } = store.getState().room;
+ store.dispatch(connectSocket());
+
+ namespace.options.onConnected();
+
+ expect(emissionsFor(namespace, Protocol.JOIN_ROOM)).toHaveLength(1);
+ expect(store.getState().room.fetching).toBe(false);
+ expect(store.getState().room.attempts).toBe(attempts);
+ });
+
+ it('persists while disconnected and submits only after the room join acknowledgement', () => {
+ const { namespace, store } = buildStore();
+ store.dispatch(connectSocket());
+
+ store.dispatch(submitResult(result));
+
+ expect(window.localStorage.getItem(PENDING_RESULT_STORAGE_KEY)).not.toBeNull();
+ expect(emissionsFor(namespace, Protocol.SUBMIT_RESULT)).toHaveLength(0);
+
+ namespace.options.onConnected();
+ expect(emissionsFor(namespace, Protocol.JOIN_ROOM)).toHaveLength(1);
+ expect(emissionsFor(namespace, Protocol.SUBMIT_RESULT)).toHaveLength(0);
+
+ acknowledgeJoin(namespace);
+
+ const submission = emissionsFor(namespace, Protocol.SUBMIT_RESULT)[0];
+ expect(submission.pendingResultAtEmit).not.toBeNull();
+ expect(JSON.parse(submission.pendingResultAtEmit).deliveryAttempted).toBe(true);
+ expect(submission.args[0]).toEqual({
+ id: 12,
+ attemptKey: 'attempt-one',
+ result: result.result,
+ submissionId: 'submission-one',
+ });
+
+ submission.args[1](null, {
+ submissionId: 'submission-one',
+ status: 'saved',
+ });
+
+ expect(window.localStorage.getItem(PENDING_RESULT_STORAGE_KEY)).toBeNull();
+ expect(store.getState().room.resultSubmission.status).toBe('idle');
+ });
+
+ it('still submits from memory when local storage is unavailable', () => {
+ const storage = {
+ getItem: jest.fn(() => null),
+ setItem: jest.fn(() => {
+ throw new Error('storage unavailable');
+ }),
+ removeItem: jest.fn(),
+ };
+ const { namespace, store } = buildStore({ storage });
+ store.dispatch(connectSocket());
+ namespace.options.onConnected();
+ acknowledgeJoin(namespace);
+
+ store.dispatch(submitResult(result));
+
+ expect(storage.setItem).toHaveBeenCalledTimes(2);
+ expect(emissionsFor(namespace, Protocol.SUBMIT_RESULT)).toHaveLength(1);
+ expect(store.getState().room.resultSubmission.status).toBe('sending');
+ expect(store.getState().messages.messages.some((message) => (
+ message.text.includes('Keep this tab open')
+ ))).toBe(true);
+ });
+
+ it('retries a transient error with the same submission id', () => {
+ const { namespace, scheduler, store } = buildStore();
+ store.dispatch(connectSocket());
+ namespace.options.onConnected();
+ acknowledgeJoin(namespace);
+
+ store.dispatch(submitResult(result));
+ const firstSubmission = emissionsFor(namespace, Protocol.SUBMIT_RESULT)[0];
+ firstSubmission.args[1]({ message: 'MongoDB unavailable', retryable: true });
+
+ expect(store.getState().room.resultSubmission.status).toBe('pending');
+ scheduler.runNext(25);
+
+ const submissions = emissionsFor(namespace, Protocol.SUBMIT_RESULT);
+ expect(submissions).toHaveLength(2);
+ expect(submissions[1].args[0].submissionId).toBe(submissions[0].args[0].submissionId);
+ });
+
+ it('does not discard a result after delivery has started', () => {
+ const { namespace, scheduler, store } = buildStore();
+ store.dispatch(connectSocket());
+ namespace.options.onConnected();
+ acknowledgeJoin(namespace);
+
+ store.dispatch(submitResult(result));
+ scheduler.runNext(100);
+ store.dispatch(discardPendingResult('submission-one'));
+
+ expect(store.getState().room.resultSubmission.status).toBe('pending');
+ expect(window.localStorage.getItem(PENDING_RESULT_STORAGE_KEY)).not.toBeNull();
+
+ scheduler.runNext(25);
+ expect(emissionsFor(namespace, Protocol.SUBMIT_RESULT)).toHaveLength(2);
+ });
+
+ it('protects a restored result that may already have reached the server', () => {
+ const restoredResult = markPendingResultAttempted(createPendingResult({
+ userId: 42,
+ roomId: 'room-one',
+ attemptId: 12,
+ attemptKey: 'attempt-one',
+ result: { time: 1234, penalties: {} },
+ }, {
+ createId: () => 'restored-submission',
+ now: () => 500,
+ }));
+ persistPendingResult(restoredResult);
+
+ const { store } = buildStore();
+ store.dispatch(discardPendingResult('restored-submission'));
+
+ expect(store.getState().room.resultSubmission.pendingResult).toEqual(restoredResult);
+ expect(window.localStorage.getItem(PENDING_RESULT_STORAGE_KEY)).not.toBeNull();
+ });
+
+ it('rejects an invalid result without blocking the next valid result', () => {
+ const { namespace, store } = buildStore();
+ store.dispatch(connectSocket());
+ namespace.options.onConnected();
+ acknowledgeJoin(namespace);
+
+ store.dispatch(submitResult({
+ ...result,
+ result: { time: undefined, penalties: {} },
+ }));
+
+ expect(store.getState().room.resultSubmission.status).toBe('idle');
+ expect(emissionsFor(namespace, Protocol.SUBMIT_RESULT)).toHaveLength(0);
+
+ store.dispatch(submitResult(result));
+ expect(emissionsFor(namespace, Protocol.SUBMIT_RESULT)).toHaveLength(1);
+ });
+
+ it('accepts an exact self result echo as a legacy persistence receipt', () => {
+ const { namespace, store } = buildStore();
+ store.dispatch(connectSocket());
+ namespace.options.onConnected();
+ acknowledgeJoin(namespace);
+
+ store.dispatch(submitResult(result));
+ const submission = emissionsFor(namespace, Protocol.SUBMIT_RESULT)[0];
+
+ namespace.options.events[Protocol.NEW_RESULT]({
+ id: 12,
+ userId: 42,
+ result: { time: 1234, penalties: { DNF: true } },
+ });
+ expect(store.getState().room.resultSubmission.status).toBe('sending');
+
+ namespace.options.events[Protocol.NEW_RESULT]({
+ id: 12,
+ userId: 42,
+ result: { time: 1234, penalties: {}, submissionId: 'another-submission' },
+ });
+ expect(store.getState().room.resultSubmission.status).toBe('sending');
+
+ namespace.options.events[Protocol.NEW_RESULT]({
+ id: 12,
+ userId: 42,
+ result: { time: 1234, penalties: {}, submissionId: 'submission-one' },
+ });
+ expect(store.getState().room.resultSubmission.status).toBe('sending');
+
+ namespace.options.events[Protocol.NEW_RESULT]({
+ id: 12,
+ userId: 42,
+ result: { time: 1234, penalties: {} },
+ });
+
+ expect(window.localStorage.getItem(PENDING_RESULT_STORAGE_KEY)).toBeNull();
+ expect(store.getState().room.resultSubmission.status).toBe('idle');
+
+ submission.args[1](null, {
+ submissionId: 'submission-one',
+ status: 'saved',
+ });
+ expect(store.getState().room.resultSubmission.status).toBe('idle');
+ });
+
+ it('retains a terminal failure until the user explicitly discards it', () => {
+ const { namespace, store } = buildStore();
+ store.dispatch(connectSocket());
+ namespace.options.onConnected();
+ acknowledgeJoin(namespace);
+
+ store.dispatch(submitResult(result));
+ const submission = emissionsFor(namespace, Protocol.SUBMIT_RESULT)[0];
+ submission.args[1]({ message: 'Attempt already has a result', retryable: false });
+
+ expect(store.getState().room.resultSubmission.status).toBe('failed');
+ expect(JSON.parse(
+ window.localStorage.getItem(PENDING_RESULT_STORAGE_KEY),
+ ).terminalFailure).toBe(true);
+
+ store.dispatch(discardPendingResult('submission-one'));
+
+ expect(store.getState().room.resultSubmission.status).toBe('idle');
+ expect(window.localStorage.getItem(PENDING_RESULT_STORAGE_KEY)).toBeNull();
+ });
+
+ it('restores a terminal failure as discardable after a refresh', () => {
+ const failedResult = markPendingResultFailed(createPendingResult({
+ userId: 42,
+ roomId: 'room-one',
+ attemptId: 12,
+ attemptKey: 'attempt-one',
+ result: { time: 1234, penalties: {} },
+ }, {
+ createId: () => 'restored-submission',
+ now: () => 500,
+ }), {
+ statusCode: 409,
+ message: 'A result already exists for this attempt',
+ retryable: false,
+ });
+ persistPendingResult(failedResult);
+
+ const { store } = buildStore();
+ store.dispatch(discardPendingResult('restored-submission'));
+
+ expect(store.getState().room.resultSubmission.status).toBe('idle');
+ expect(window.localStorage.getItem(PENDING_RESULT_STORAGE_KEY)).toBeNull();
+ });
+
+ it('does not submit a restored result for a different user or room', () => {
+ const restoredResult = createPendingResult({
+ userId: 99,
+ roomId: 'room-two',
+ attemptId: 8,
+ attemptKey: 'attempt-two',
+ result: { time: 999, penalties: {} },
+ }, {
+ createId: () => 'restored-submission',
+ now: () => 500,
+ });
+ persistPendingResult(restoredResult);
+
+ const { namespace, store } = buildStore();
+ store.dispatch(connectSocket());
+ namespace.options.onConnected();
+ acknowledgeJoin(namespace);
+
+ expect(store.getState().room.resultSubmission.pendingResult).toEqual(restoredResult);
+ expect(emissionsFor(namespace, Protocol.SUBMIT_RESULT)).toHaveLength(0);
+ });
+
+ it('replays an interrupted initial room join after reconnecting', () => {
+ const { namespace, store } = buildStore({
+ roomState: {
+ ...initialRoom(),
+ _id: null,
+ accessCode: null,
+ },
+ });
+ store.dispatch(connectSocket());
+ namespace.options.onConnected();
+ store.dispatch(joinRoom({ id: 'room-two', password: 'secret' }));
+
+ expect(emissionsFor(namespace, Protocol.JOIN_ROOM)).toHaveLength(1);
+ namespace.options.onDisconnected();
+ namespace.options.onConnected();
+
+ const joins = emissionsFor(namespace, Protocol.JOIN_ROOM);
+ expect(joins).toHaveLength(2);
+ expect(joins[1].args[0]).toEqual({ id: 'room-two', password: 'secret' });
+
+ joins[1].args[1](null, {
+ ...initialRoom(),
+ _id: 'room-two',
+ accessCode: 'ROOM2',
+ });
+ expect(store.getState().room.fetching).toBe(false);
+ expect(store.getState().room._id).toBe('room-two');
+ });
+});
diff --git a/client/src/store/room/actions.js b/client/src/store/room/actions.js
index 811b8cd..e954a92 100644
--- a/client/src/store/room/actions.js
+++ b/client/src/store/room/actions.js
@@ -27,6 +27,11 @@ export const NEXT_SOLVE_AT = 'room/next_solve_at';
export const START_ROOM = 'room/start_room';
export const PAUSE_ROOM = 'room/pause_room';
export const TOGGLE_FOLLOW_USER = 'room/toggle_follow_user';
+export const RESULT_SUBMISSION_PENDING = 'room/result_submission_pending';
+export const RESULT_SUBMISSION_SENDING = 'room/result_submission_sending';
+export const RESULT_SUBMISSION_FAILED = 'room/result_submission_failed';
+export const RESULT_SUBMISSION_CLEARED = 'room/result_submission_cleared';
+export const DISCARD_PENDING_RESULT = 'room/discard_pending_result';
export const roomUpdated = (room) => ({
type: ROOM_UPDATED,
@@ -43,11 +48,14 @@ export const deleteRoom = (id) => ({
id,
});
-export const joinRoom = ({ id, spectating, password }) => ({
+export const joinRoom = ({
+ id, spectating, password, reconnecting = false,
+}) => ({
type: JOIN_ROOM,
id,
spectating,
password,
+ reconnecting,
});
// We're submitting a new result
@@ -56,6 +64,32 @@ export const submitResult = (result) => ({
result,
});
+export const resultSubmissionPending = (pendingResult) => ({
+ type: RESULT_SUBMISSION_PENDING,
+ pendingResult,
+});
+
+export const resultSubmissionSending = (submissionId) => ({
+ type: RESULT_SUBMISSION_SENDING,
+ submissionId,
+});
+
+export const resultSubmissionFailed = (submissionId, error) => ({
+ type: RESULT_SUBMISSION_FAILED,
+ submissionId,
+ error,
+});
+
+export const resultSubmissionCleared = (submissionId) => ({
+ type: RESULT_SUBMISSION_CLEARED,
+ submissionId,
+});
+
+export const discardPendingResult = (submissionId) => ({
+ type: DISCARD_PENDING_RESULT,
+ submissionId,
+});
+
// A new result came in
export const newResult = (result) => ({
type: NEW_RESULT,
diff --git a/client/src/store/room/reducer.js b/client/src/store/room/reducer.js
index f371728..0a7fd65 100644
--- a/client/src/store/room/reducer.js
+++ b/client/src/store/room/reducer.js
@@ -16,8 +16,18 @@ import {
UPDATE_USER_BANNED,
NEXT_SOLVE_AT,
TOGGLE_FOLLOW_USER,
+ RESULT_SUBMISSION_PENDING,
+ RESULT_SUBMISSION_SENDING,
+ RESULT_SUBMISSION_FAILED,
+ RESULT_SUBMISSION_CLEARED,
} from './actions';
+const EMPTY_RESULT_SUBMISSION = {
+ status: 'idle',
+ pendingResult: null,
+ error: null,
+};
+
const INITIAL_STATE = {
fetching: null,
_id: null,
@@ -47,21 +57,30 @@ const INITIAL_STATE = {
following: {},
registeredUsers: 0,
twitchChannel: '',
+ resultSubmission: EMPTY_RESULT_SUBMISSION,
};
-const editResult = (state, action) => ({
- ...state,
- attempts: state.attempts.map((attempt) => (
- attempt.id === action.result.id ? ({
- ...attempt,
- results: { ...attempt.results, [action.result.userId]: action.result.result },
- }) : attempt
- )),
- waitingFor: {
- ...state.waitingFor,
- [action.result.userId]: false,
- },
-});
+const editResult = (state, action) => {
+ const latestAttempt = state.attempts[state.attempts.length - 1];
+ const resultIsForLatestAttempt = latestAttempt
+ && latestAttempt.id === action.result.id;
+
+ return {
+ ...state,
+ attempts: state.attempts.map((attempt) => (
+ attempt.id === action.result.id ? ({
+ ...attempt,
+ results: { ...attempt.results, [action.result.userId]: action.result.result },
+ }) : attempt
+ )),
+ waitingFor: resultIsForLatestAttempt
+ ? {
+ ...state.waitingFor,
+ [action.result.userId]: false,
+ }
+ : state.waitingFor,
+ };
+};
const reducers = {
[ROOM_UPDATED]: (state, { room }) => ({
@@ -94,14 +113,16 @@ const reducers = {
}),
[JOIN_ROOM]: (state, action) => ({
...state,
- fetching: true,
+ fetching: action.reconnecting ? state.fetching : true,
password: action.password,
}),
- [RESET_ROOM]: () => ({
+ [RESET_ROOM]: (state) => ({
...INITIAL_STATE,
+ resultSubmission: state.resultSubmission,
}),
- [LEAVE_ROOM]: () => ({
+ [LEAVE_ROOM]: (state) => ({
...INITIAL_STATE,
+ resultSubmission: state.resultSubmission,
}),
[NEW_ATTEMPT]: (state, action) => ({
...state,
@@ -151,6 +172,49 @@ const reducers = {
[action.userId]: !state.following[action.userId],
},
}),
+ [RESULT_SUBMISSION_PENDING]: (state, action) => ({
+ ...state,
+ resultSubmission: {
+ status: action.pendingResult.terminalFailure ? 'failed' : 'pending',
+ pendingResult: action.pendingResult,
+ error: action.pendingResult.terminalFailure ? action.pendingResult.failure : null,
+ },
+ }),
+ [RESULT_SUBMISSION_SENDING]: (state, action) => (
+ state.resultSubmission.pendingResult
+ && state.resultSubmission.pendingResult.submissionId === action.submissionId
+ ? {
+ ...state,
+ resultSubmission: {
+ ...state.resultSubmission,
+ status: 'sending',
+ error: null,
+ },
+ }
+ : state
+ ),
+ [RESULT_SUBMISSION_FAILED]: (state, action) => (
+ state.resultSubmission.pendingResult
+ && state.resultSubmission.pendingResult.submissionId === action.submissionId
+ ? {
+ ...state,
+ resultSubmission: {
+ ...state.resultSubmission,
+ status: 'failed',
+ error: action.error,
+ },
+ }
+ : state
+ ),
+ [RESULT_SUBMISSION_CLEARED]: (state, action) => (
+ state.resultSubmission.pendingResult
+ && state.resultSubmission.pendingResult.submissionId === action.submissionId
+ ? {
+ ...state,
+ resultSubmission: EMPTY_RESULT_SUBMISSION,
+ }
+ : state
+ ),
};
// Socket reducer
diff --git a/client/src/store/room/reducer.test.js b/client/src/store/room/reducer.test.js
new file mode 100644
index 0000000..f8181f5
--- /dev/null
+++ b/client/src/store/room/reducer.test.js
@@ -0,0 +1,66 @@
+import { joinRoom, newResult } from './actions';
+import roomReducer from './reducer';
+
+const initialState = () => ({
+ ...roomReducer(undefined, { type: '@@INIT' }),
+ attempts: [{
+ _id: 'old-attempt',
+ id: 0,
+ results: {},
+ }, {
+ _id: 'latest-attempt',
+ id: 1,
+ results: {},
+ }],
+ waitingFor: { 42: true },
+});
+
+describe('room result updates', () => {
+ it('does not clear current waiting state for a delayed historical result', () => {
+ const state = roomReducer(initialState(), newResult({
+ id: 0,
+ userId: 42,
+ result: { time: 1234, penalties: {} },
+ }));
+
+ expect(state.attempts[0].results[42]).toEqual({ time: 1234, penalties: {} });
+ expect(state.waitingFor[42]).toBe(true);
+ });
+
+ it('clears waiting state when the result belongs to the latest attempt', () => {
+ const state = roomReducer(initialState(), newResult({
+ id: 1,
+ userId: 42,
+ result: { time: 2345, penalties: {} },
+ }));
+
+ expect(state.attempts[1].results[42]).toEqual({ time: 2345, penalties: {} });
+ expect(state.waitingFor[42]).toBe(false);
+ });
+});
+
+describe('room joins', () => {
+ it('shows the loading state for an initial room join', () => {
+ const state = roomReducer(undefined, joinRoom({ id: 'room-1' }));
+
+ expect(state.fetching).toBe(true);
+ });
+
+ it('keeps an active room mounted during a reconnect join', () => {
+ const loadedRoom = {
+ ...roomReducer(undefined, { type: '@@INIT' }),
+ fetching: false,
+ _id: 'room-1',
+ accessCode: 'access-code',
+ attempts: [{ _id: 'attempt-1', id: 0 }],
+ };
+
+ const state = roomReducer(loadedRoom, joinRoom({
+ id: 'room-1',
+ reconnecting: true,
+ }));
+
+ expect(state.fetching).toBe(false);
+ expect(state.attempts).toBe(loadedRoom.attempts);
+ });
+});
diff --git a/client/src/store/room/resultOutbox.js b/client/src/store/room/resultOutbox.js
new file mode 100644
index 0000000..265adb5
--- /dev/null
+++ b/client/src/store/room/resultOutbox.js
@@ -0,0 +1,152 @@
+import { uuid } from '../../lib/utils';
+
+export const PENDING_RESULT_VERSION = 1;
+export const PENDING_RESULT_STORAGE_KEY = 'letscube.pendingResult.v1';
+
+const hasId = (value) => (
+ (typeof value === 'string' && value.length > 0)
+ || (typeof value === 'number' && Number.isFinite(value))
+);
+
+export const isPendingResult = (pendingResult) => (
+ !!pendingResult
+ && pendingResult.version === PENDING_RESULT_VERSION
+ && typeof pendingResult.submissionId === 'string'
+ && pendingResult.submissionId.length > 0
+ && hasId(pendingResult.userId)
+ && hasId(pendingResult.roomId)
+ && hasId(pendingResult.attemptId)
+ && (pendingResult.attemptKey === undefined || hasId(pendingResult.attemptKey))
+ && (pendingResult.deliveryAttempted === undefined
+ || typeof pendingResult.deliveryAttempted === 'boolean')
+ && (pendingResult.terminalFailure === undefined
+ || typeof pendingResult.terminalFailure === 'boolean')
+ && (!pendingResult.terminalFailure
+ || (!!pendingResult.failure
+ && typeof pendingResult.failure === 'object'
+ && typeof pendingResult.failure.message === 'string'
+ && pendingResult.failure.message.length > 0))
+ && !!pendingResult.result
+ && typeof pendingResult.result === 'object'
+ && typeof pendingResult.result.time === 'number'
+ && Number.isFinite(pendingResult.result.time)
+ && typeof pendingResult.createdAt === 'number'
+ && Number.isFinite(pendingResult.createdAt)
+);
+
+export const createPendingResult = ({
+ userId,
+ roomId,
+ attemptId,
+ attemptKey,
+ result,
+}, {
+ createId = uuid,
+ now = Date.now,
+} = {}) => {
+ if (!hasId(attemptKey)) {
+ throw new Error('A new pending result requires an immutable attempt key.');
+ }
+
+ const pendingResult = {
+ version: PENDING_RESULT_VERSION,
+ submissionId: createId(),
+ userId,
+ roomId,
+ attemptId,
+ attemptKey,
+ deliveryAttempted: false,
+ terminalFailure: false,
+ result,
+ createdAt: now(),
+ };
+
+ if (!isPendingResult(pendingResult)) {
+ throw new Error('Cannot create an invalid pending result.');
+ }
+
+ return pendingResult;
+};
+
+export const markPendingResultAttempted = (pendingResult) => {
+ const attemptedResult = {
+ ...pendingResult,
+ deliveryAttempted: true,
+ };
+
+ if (!isPendingResult(attemptedResult)) {
+ throw new Error('Cannot mark an invalid pending result as attempted.');
+ }
+
+ return attemptedResult;
+};
+
+export const markPendingResultFailed = (pendingResult, failure) => {
+ const failedResult = {
+ ...pendingResult,
+ deliveryAttempted: true,
+ terminalFailure: true,
+ failure,
+ };
+
+ if (!isPendingResult(failedResult)) {
+ throw new Error('Cannot mark an invalid pending result as failed.');
+ }
+
+ return failedResult;
+};
+
+export const canDiscardPendingResult = (pendingResult, status) => (
+ isPendingResult(pendingResult)
+ && (status === 'failed'
+ || pendingResult.terminalFailure === true
+ || pendingResult.deliveryAttempted !== true)
+);
+
+export const readPendingResult = (storage = window.localStorage) => {
+ const serialized = storage.getItem(PENDING_RESULT_STORAGE_KEY);
+ if (!serialized) {
+ return null;
+ }
+
+ try {
+ const pendingResult = JSON.parse(serialized);
+ return isPendingResult(pendingResult) ? pendingResult : null;
+ } catch (error) {
+ return null;
+ }
+};
+
+export const persistPendingResult = (pendingResult, storage = window.localStorage) => {
+ if (!isPendingResult(pendingResult)) {
+ throw new Error('Cannot save an invalid pending result.');
+ }
+
+ storage.setItem(PENDING_RESULT_STORAGE_KEY, JSON.stringify(pendingResult));
+ return pendingResult;
+};
+
+export const clearPendingResult = (submissionId, storage = window.localStorage) => {
+ const pendingResult = readPendingResult(storage);
+
+ if (pendingResult && pendingResult.submissionId !== submissionId) {
+ return false;
+ }
+
+ storage.removeItem(PENDING_RESULT_STORAGE_KEY);
+ return true;
+};
+
+export const pendingResultMatches = (pendingResult, { userId, roomId }) => (
+ isPendingResult(pendingResult)
+ && hasId(userId)
+ && hasId(roomId)
+ && String(pendingResult.userId) === String(userId)
+ && String(pendingResult.roomId) === String(roomId)
+);
+
+export const pendingResultBelongsToUser = (pendingResult, userId) => (
+ isPendingResult(pendingResult)
+ && hasId(userId)
+ && String(pendingResult.userId) === String(userId)
+);
diff --git a/client/src/store/room/resultOutbox.test.js b/client/src/store/room/resultOutbox.test.js
new file mode 100644
index 0000000..014a409
--- /dev/null
+++ b/client/src/store/room/resultOutbox.test.js
@@ -0,0 +1,121 @@
+import {
+ PENDING_RESULT_STORAGE_KEY,
+ canDiscardPendingResult,
+ clearPendingResult,
+ createPendingResult,
+ markPendingResultAttempted,
+ markPendingResultFailed,
+ pendingResultBelongsToUser,
+ pendingResultMatches,
+ persistPendingResult,
+ readPendingResult,
+} from './resultOutbox';
+
+const makePendingResult = (overrides = {}) => createPendingResult({
+ userId: 42,
+ roomId: 'room-one',
+ attemptId: 12,
+ attemptKey: 'attempt-one',
+ result: {
+ time: 1234,
+ penalties: {},
+ },
+ ...overrides,
+}, {
+ createId: () => 'submission-one',
+ now: () => 1000,
+});
+
+describe('result outbox storage', () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ });
+
+ it('persists and restores the complete pending result', () => {
+ const pendingResult = makePendingResult();
+
+ persistPendingResult(pendingResult);
+
+ const serialized = window.localStorage.getItem(PENDING_RESULT_STORAGE_KEY);
+ expect(JSON.parse(serialized)).toEqual(pendingResult);
+ expect(readPendingResult()).toEqual(pendingResult);
+ });
+
+ it('only clears the matching submission', () => {
+ const pendingResult = makePendingResult();
+ persistPendingResult(pendingResult);
+
+ expect(clearPendingResult('another-submission')).toBe(false);
+ expect(readPendingResult()).toEqual(pendingResult);
+
+ expect(clearPendingResult(pendingResult.submissionId)).toBe(true);
+ expect(readPendingResult()).toBeNull();
+ });
+
+ it('matches both user and room before allowing automatic submission', () => {
+ const pendingResult = makePendingResult();
+
+ expect(pendingResultBelongsToUser(pendingResult, '42')).toBe(true);
+ expect(pendingResultMatches(
+ pendingResult,
+ { userId: '42', roomId: 'room-one' },
+ )).toBe(true);
+ expect(pendingResultMatches(pendingResult, { userId: 7, roomId: 'room-one' })).toBe(false);
+ expect(pendingResultMatches(pendingResult, { userId: 42, roomId: 'room-two' })).toBe(false);
+ });
+
+ it('does not restore malformed storage data', () => {
+ window.localStorage.setItem(PENDING_RESULT_STORAGE_KEY, '{not-json');
+ expect(readPendingResult()).toBeNull();
+
+ window.localStorage.setItem(PENDING_RESULT_STORAGE_KEY, JSON.stringify({ version: 1 }));
+ expect(readPendingResult()).toBeNull();
+ });
+
+ it('restores legacy pending results without an attempt key', () => {
+ const pendingResult = makePendingResult();
+ delete pendingResult.attemptKey;
+ window.localStorage.setItem(
+ PENDING_RESULT_STORAGE_KEY,
+ JSON.stringify(pendingResult),
+ );
+
+ expect(readPendingResult()).toEqual(pendingResult);
+ });
+
+ it('requires new pending results to identify the immutable attempt', () => {
+ expect(() => makePendingResult({ attemptKey: undefined })).toThrow(
+ 'requires an immutable attempt key',
+ );
+ });
+
+ it.each([undefined, Number.NaN])('rejects an invalid result time', (time) => {
+ expect(() => makePendingResult({
+ result: { time, penalties: {} },
+ })).toThrow('Cannot create an invalid pending result');
+ });
+
+ it('protects a result from discard after delivery starts', () => {
+ const pendingResult = makePendingResult();
+ const attemptedResult = markPendingResultAttempted(pendingResult);
+
+ expect(canDiscardPendingResult(pendingResult, 'pending')).toBe(true);
+ expect(attemptedResult.deliveryAttempted).toBe(true);
+ expect(canDiscardPendingResult(attemptedResult, 'sending')).toBe(false);
+ expect(canDiscardPendingResult(attemptedResult, 'pending')).toBe(false);
+ expect(canDiscardPendingResult(attemptedResult, 'failed')).toBe(true);
+ });
+
+ it('persists a terminal failure as safe to discard', () => {
+ const failedResult = markPendingResultFailed(makePendingResult(), {
+ statusCode: 409,
+ message: 'A result already exists for this attempt',
+ retryable: false,
+ });
+ persistPendingResult(failedResult);
+
+ const restoredResult = readPendingResult();
+ expect(restoredResult).toEqual(failedResult);
+ expect(canDiscardPendingResult(restoredResult, 'pending')).toBe(true);
+ });
+});
diff --git a/client/src/theme.jsx b/client/src/theme.jsx
index aeae1d0..f932545 100644
--- a/client/src/theme.jsx
+++ b/client/src/theme.jsx
@@ -7,8 +7,8 @@ import {
} from '@material-ui/core/colors';
import {
createMuiTheme,
- ThemeProvider as MuiThemeProvider,
} from '@material-ui/core/styles';
+import MuiThemeProvider from '@material-ui/styles/ThemeProvider';
import useMediaQuery from '@material-ui/core/useMediaQuery';
const defaultTheme = createMuiTheme();
diff --git a/compose.dev.yml b/compose.dev.yml
index 6ced287..a1689a4 100644
--- a/compose.dev.yml
+++ b/compose.dev.yml
@@ -1,4 +1,8 @@
services:
+ migrate:
+ profiles:
+ - migrations
+
client:
image: node:22-bookworm
working_dir: /app
diff --git a/compose.prod.yml b/compose.prod.yml
index 687a6b3..f75bc21 100644
--- a/compose.prod.yml
+++ b/compose.prod.yml
@@ -7,6 +7,9 @@ x-app-build: &app-build
REACT_APP_WCA_CLIENT_ID: ${REACT_APP_WCA_CLIENT_ID:-}
services:
+ migrate:
+ build: *app-build
+
api:
build: *app-build
restart: unless-stopped
@@ -18,6 +21,9 @@ services:
mongo:
restart: unless-stopped
+ postgres:
+ restart: unless-stopped
+
redis:
restart: unless-stopped
diff --git a/compose.yml b/compose.yml
index 72ceca4..e22418e 100644
--- a/compose.yml
+++ b/compose.yml
@@ -4,8 +4,21 @@ x-app-environment: &app-environment
NODE_ENV: ${NODE_ENV:-prod}
PORT: ${PORT:-8080}
SOCKETIO_PORT: ${SOCKETIO_PORT:-9000}
+ ROOM_RECONNECT_GRACE_MS: ${ROOM_RECONNECT_GRACE_MS:-60000}
+ GRAND_PRIX_ENABLED: ${GRAND_PRIX_ENABLED:-false}
MONGO_URL: ${MONGO_URL:-mongodb://mongo:27017/letscube}
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
+ POSTGRES_ENABLED: ${POSTGRES_ENABLED:-true}
+ DATABASE_URL: ${DATABASE_URL:-}
+ DIRECT_DATABASE_URL: ${DIRECT_DATABASE_URL:-}
+ PGHOST: ${PGHOST:-postgres}
+ PGPORT: ${PGPORT:-5432}
+ PGDATABASE: ${PGDATABASE:-letscube}
+ PGUSER: ${PGUSER:-letscube}
+ PGPASSWORD: ${POSTGRES_PASSWORD:-change-me}
+ PGSSL: ${PGSSL:-false}
+ PGSSL_REJECT_UNAUTHORIZED: ${PGSSL_REJECT_UNAUTHORIZED:-true}
+ PGSSL_CA: ${PGSSL_CA:-}
AUTH_SECRET: ${AUTH_SECRET:-change-me}
SESSION_SECRET: ${SESSION_SECRET:-}
AUTH_CALLBACK_URL: ${AUTH_CALLBACK_URL:-https://letscube.net/auth/callback}
@@ -15,6 +28,16 @@ x-app-environment: &app-environment
CORS_ORIGINS: ${CORS_ORIGINS:-https://letscube.net,https://www.letscube.net}
services:
+ migrate:
+ image: letscube-app:${APP_IMAGE_TAG:-local}
+ command: ["./node_modules/.bin/prisma", "migrate", "deploy", "--config", "server/prisma.config.mjs"]
+ environment:
+ <<: *app-environment
+ depends_on:
+ postgres:
+ condition: service_healthy
+ restart: "no"
+
api:
image: letscube-app:${APP_IMAGE_TAG:-local}
command: ["node", "server/index.js"]
@@ -28,8 +51,9 @@ services:
expose:
- "8080"
healthcheck:
- test: ["CMD-SHELL", "node -e \"require('http').get({host:'127.0.0.1',port:process.env.PORT||8080,path:'/'},res=>process.exit(res.statusCode<500?0:1)).on('error',()=>process.exit(1))\""]
+ test: ["CMD-SHELL", "node -e \"require('http').get({host:'127.0.0.1',port:process.env.PORT||8080,path:'/health/api'},res=>process.exit(res.statusCode===200?0:1)).on('error',()=>process.exit(1))\""]
interval: 30s
+ start_interval: 2s
timeout: 5s
retries: 5
start_period: 30s
@@ -47,8 +71,9 @@ services:
expose:
- "9000"
healthcheck:
- test: ["CMD-SHELL", "node -e \"require('http').get({host:'127.0.0.1',port:process.env.SOCKETIO_PORT||9000,path:'/socket.io/?EIO=4&transport=polling'},res=>process.exit(res.statusCode<500?0:1)).on('error',()=>process.exit(1))\""]
+ test: ["CMD-SHELL", "node -e \"require('http').get({host:'127.0.0.1',port:process.env.SOCKETIO_PORT||9000,path:'/health/socket'},res=>process.exit(res.statusCode===200?0:1)).on('error',()=>process.exit(1))\""]
interval: 30s
+ start_interval: 2s
timeout: 5s
retries: 5
start_period: 30s
@@ -64,6 +89,21 @@ services:
retries: 5
start_period: 30s
+ postgres:
+ image: postgres:17-alpine
+ environment:
+ POSTGRES_DB: ${PGDATABASE:-letscube}
+ POSTGRES_USER: ${PGUSER:-letscube}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me}
+ volumes:
+ - postgres-data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${PGUSER:-letscube} -d ${PGDATABASE:-letscube}"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 10s
+
redis:
image: redis:7.2-alpine
command: ["redis-server", "--appendonly", "yes"]
@@ -77,4 +117,5 @@ services:
volumes:
mongo-data:
+ postgres-data:
redis-data:
diff --git a/docker-compose.yml b/docker-compose.yml
index 01224e2..8acefc8 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,4 +1,21 @@
services:
+ postgres:
+ image: postgres:17-alpine
+ ports:
+ - "127.0.0.1:55433:5432"
+ environment:
+ POSTGRES_DB: letscube
+ POSTGRES_USER: letscube
+ POSTGRES_PASSWORD: letscube
+ volumes:
+ - postgres-data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U letscube -d letscube"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ restart: unless-stopped
+
mongo:
image: mongo:6.0
ports:
@@ -23,4 +40,5 @@ services:
volumes:
mongo-data:
+ postgres-data:
redis-data:
diff --git a/nginx.conf b/nginx.conf
index ac675c1..88c98f2 100644
--- a/nginx.conf
+++ b/nginx.conf
@@ -29,6 +29,10 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
+ location = /health/socket {
+ proxy_pass http://socket:9000/health/socket;
+ }
+
location /socket.io/ {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
diff --git a/package.json b/package.json
index f94b7f5..5790db9 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,7 @@
"server"
],
"resolutions": {
+ "@hono/node-server": "1.19.13",
"cheerio": "1.0.0-rc.12"
},
"scripts": {
diff --git a/scripts/classify-deploy-target.sh b/scripts/classify-deploy-target.sh
new file mode 100755
index 0000000..1afbb6c
--- /dev/null
+++ b/scripts/classify-deploy-target.sh
@@ -0,0 +1,41 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Classify changed paths for scripts/deploy.sh. Paths may be passed as
+# arguments or, for ad-hoc use, one per line on stdin.
+if [ "$#" -eq 0 ] && [ ! -t 0 ]; then
+ mapfile -t changed_paths
+else
+ changed_paths=("$@")
+fi
+
+target="none"
+
+for path in "${changed_paths[@]}"; do
+ case "$path" in
+ # Repository documentation and CI/agent metadata do not affect runtime.
+ .github/*|.agents/*|.codex/*|docs/*|AGENTS.md|README.md|README_*.md|LICENSE|LICENSE.*|*.md)
+ ;;
+
+ # The server imports this client-owned protocol file at runtime.
+ client/src/lib/protocol.js|client/src/lib/protocol.json|client/src/lib/events.json)
+ echo "all"
+ exit 0
+ ;;
+
+ # The API image serves the built browser client. A client-only change does
+ # not require interrupting existing Socket.IO connections.
+ client/*)
+ target="api"
+ ;;
+
+ # Server, dependency, container, deployment, and unknown changes are
+ # intentionally conservative because API and socket share one image.
+ *)
+ echo "all"
+ exit 0
+ ;;
+ esac
+done
+
+echo "$target"
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
index 4f292e4..b177e38 100755
--- a/scripts/deploy.sh
+++ b/scripts/deploy.sh
@@ -7,8 +7,35 @@ REMOTE="${REMOTE:-origin}"
BRANCH="${BRANCH:-master}"
DEPLOY_STRATEGY="${DEPLOY_STRATEGY:-ff-only}"
DEPLOY_FORCE="${DEPLOY_FORCE:-false}"
+DEPLOY_TARGET="${DEPLOY_TARGET:-auto}"
+DEPLOY_WAIT_TIMEOUT_SECONDS="${DEPLOY_WAIT_TIMEOUT_SECONDS:-60}"
COMPOSE=(docker compose -f compose.yml -f compose.prod.yml --env-file "$ENV_FILE")
+ROLLBACK_COMPOSE=()
+rollback_compose_dir=""
+
+cleanup_deploy_files() {
+ if [ -n "$rollback_compose_dir" ]; then
+ rm -rf -- "$rollback_compose_dir"
+ fi
+}
+
+trap cleanup_deploy_files EXIT
+
+case "$DEPLOY_TARGET" in
+ auto|api|socket|all|none)
+ ;;
+ *)
+ echo "Unsupported DEPLOY_TARGET: $DEPLOY_TARGET" >&2
+ echo "Use auto, api, socket, all, or none." >&2
+ exit 2
+ ;;
+esac
+
+if ! [[ "$DEPLOY_WAIT_TIMEOUT_SECONDS" =~ ^[1-9][0-9]*$ ]]; then
+ echo "DEPLOY_WAIT_TIMEOUT_SECONDS must be a positive integer." >&2
+ exit 2
+fi
cd "$APP_DIR"
@@ -30,16 +57,251 @@ case "$DEPLOY_STRATEGY" in
esac
current_commit="$(git rev-parse HEAD)"
-if [ "$previous_commit" = "$current_commit" ] && [ "$DEPLOY_FORCE" != "true" ]; then
- echo "Already up to date at $current_commit"
- "${COMPOSE[@]}" ps
- exit 0
+
+if [ "$DEPLOY_TARGET" = "auto" ]; then
+ missing_services=()
+ for service in api socket nginx; do
+ if ! container_id="$("${COMPOSE[@]}" ps --status running -q "$service")"; then
+ echo "Failed to inspect the $service container." >&2
+ exit 1
+ fi
+ if [ -z "$container_id" ]; then
+ missing_services+=("$service")
+ fi
+ done
+
+ if [ "${#missing_services[@]}" -gt 0 ]; then
+ echo "Missing runtime services: ${missing_services[*]}; deploying all."
+ resolved_target="all"
+ elif [ "$previous_commit" = "$current_commit" ]; then
+ if [ "$DEPLOY_FORCE" != "true" ]; then
+ echo "Already up to date at $current_commit"
+ "${COMPOSE[@]}" ps
+ exit 0
+ fi
+
+ # There is no diff to classify. DEPLOY_FORCE preserves the historical
+ # behavior of replacing the complete application deployment.
+ resolved_target="all"
+ else
+ mapfile -d '' changed_files < <(
+ git diff --name-only --no-renames -z "$previous_commit" "$current_commit"
+ )
+ resolved_target="$(
+ bash scripts/classify-deploy-target.sh "${changed_files[@]}"
+ )"
+ fi
+else
+ resolved_target="$DEPLOY_TARGET"
fi
-if ! "${COMPOSE[@]}" up -d --build; then
- echo "Deployment failed. Recent service logs:" >&2
- "${COMPOSE[@]}" logs --tail=120 api socket nginx >&2 || true
+case "$resolved_target" in
+ api)
+ selected_services=(api)
+ ;;
+ socket)
+ selected_services=(socket)
+ ;;
+ all)
+ # Roll out the backward-compatible socket handler before serving the new
+ # browser client that depends on its result acknowledgments.
+ selected_services=(socket api)
+ ;;
+ none)
+ echo "Updated checkout to $current_commit; no application services selected."
+ "${COMPOSE[@]}" ps
+ exit 0
+ ;;
+ *)
+ echo "Deploy classifier returned an invalid target: $resolved_target" >&2
+ exit 2
+ ;;
+esac
+
+echo "Deploying commit $current_commit (target: $resolved_target)"
+
+print_retry_hint() {
+ echo "Retry this checked-out commit with DEPLOY_TARGET=$resolved_target after correcting the failure." >&2
+}
+
+prepare_rollback_compose() {
+ local project_dir
+
+ project_dir="$(pwd -P)"
+ rollback_compose_dir="$(mktemp -d "${TMPDIR:-/tmp}/letscube-deploy.XXXXXX")"
+
+ if ! git show "$previous_commit:compose.yml" > "$rollback_compose_dir/compose.yml" \
+ || ! git show "$previous_commit:compose.prod.yml" \
+ > "$rollback_compose_dir/compose.prod.yml"; then
+ echo "Failed to snapshot pre-deploy Compose configuration." >&2
+ return 1
+ fi
+
+ ROLLBACK_COMPOSE=(
+ docker compose
+ --project-directory "$project_dir"
+ -f "$rollback_compose_dir/compose.yml"
+ -f "$rollback_compose_dir/compose.prod.yml"
+ --env-file "$ENV_FILE"
+ )
+}
+
+if ! prepare_rollback_compose; then
+ print_retry_hint
exit 1
fi
+# Shell environment takes precedence over APP_IMAGE_TAG in the env file. This
+# gives every deployed revision an immutable image tag while keeping direct
+# Compose usage compatible with the default `local` tag.
+export APP_IMAGE_TAG="$current_commit"
+
+if ! "${COMPOSE[@]}" build api; then
+ echo "Application image build failed; no application containers were replaced." >&2
+ print_retry_hint
+ exit 1
+fi
+
+if ! "${COMPOSE[@]}" up -d --no-recreate --wait \
+ --wait-timeout "$DEPLOY_WAIT_TIMEOUT_SECONDS" mongo postgres redis; then
+ echo "Failed to start backing services; no application containers were replaced." >&2
+ print_retry_hint
+ exit 1
+fi
+
+if ! "${COMPOSE[@]}" run --rm --no-deps migrate; then
+ echo "PostgreSQL migration failed; application containers were not replaced." >&2
+ print_retry_hint
+ exit 1
+fi
+
+declare -A rollback_image_ids=()
+declare -A rollback_tags=()
+deploy_id="$(date -u +%Y%m%dT%H%M%SZ)-$$"
+
+snapshot_service_image() {
+ local service="$1"
+ local container_id
+ local image_id
+ local rollback_tag
+
+ container_id="$("${COMPOSE[@]}" ps -q "$service")"
+ if [ -z "$container_id" ]; then
+ echo "No running $service container found; this service has no rollback image."
+ return 0
+ fi
+
+ image_id="$(docker inspect --format '{{.Image}}' "$container_id")"
+ rollback_tag="rollback-${service}-${previous_commit:0:12}-${deploy_id}"
+ docker image tag "$image_id" "letscube-app:$rollback_tag"
+
+ rollback_image_ids["$service"]="$image_id"
+ rollback_tags["$service"]="$rollback_tag"
+ echo "Saved $service rollback image $image_id as letscube-app:$rollback_tag"
+}
+
+reload_nginx() {
+ local compose_variable="${1:-COMPOSE}"
+ local -n compose_command="$compose_variable"
+ local container_id
+
+ container_id="$("${compose_command[@]}" ps --status running -q nginx)"
+ if [ -z "$container_id" ]; then
+ echo "nginx is not running; starting it without replacing dependencies."
+ "${compose_command[@]}" up -d --no-deps --wait \
+ --wait-timeout "$DEPLOY_WAIT_TIMEOUT_SECONDS" nginx || return 1
+ fi
+
+ "${compose_command[@]}" exec -T nginx nginx -s reload
+}
+
+rollback_services() {
+ local services=("$@")
+ local original_tag="$APP_IMAGE_TAG"
+ local rollback_failed="false"
+ local restored_any="false"
+ local index
+ local service
+ local rollback_tag
+
+ echo "Restoring application services from their pre-deploy images." >&2
+
+ for ((index=${#services[@]} - 1; index >= 0; index--)); do
+ service="${services[$index]}"
+ rollback_tag="${rollback_tags[$service]-}"
+
+ if [ -z "$rollback_tag" ]; then
+ echo "Cannot restore $service: there was no running pre-deploy container." >&2
+ rollback_failed="true"
+ continue
+ fi
+
+ export APP_IMAGE_TAG="$rollback_tag"
+ if "${ROLLBACK_COMPOSE[@]}" up -d --no-deps --wait \
+ --wait-timeout "$DEPLOY_WAIT_TIMEOUT_SECONDS" "$service"; then
+ echo "Restored $service from ${rollback_image_ids[$service]}." >&2
+ restored_any="true"
+ else
+ echo "Failed to restore $service. Recent logs:" >&2
+ "${ROLLBACK_COMPOSE[@]}" logs --tail=120 "$service" >&2 || true
+ rollback_failed="true"
+ fi
+ done
+
+ export APP_IMAGE_TAG="$original_tag"
+
+ if [ "$restored_any" = "true" ] && ! reload_nginx ROLLBACK_COMPOSE; then
+ echo "Application service rollback completed, but nginx reload failed." >&2
+ "${ROLLBACK_COMPOSE[@]}" logs --tail=120 nginx >&2 || true
+ rollback_failed="true"
+ fi
+
+ [ "$rollback_failed" = "false" ]
+}
+
+# Snapshot every selected service before the first replacement. If a later
+# service fails, the entire application portion of this deploy can return to a
+# consistent set of exact pre-deploy images.
+for service in "${selected_services[@]}"; do
+ snapshot_service_image "$service"
+done
+
+attempted_services=()
+
+last_service_index=$((${#selected_services[@]} - 1))
+
+for service_index in "${!selected_services[@]}"; do
+ service="${selected_services[$service_index]}"
+ attempted_services+=("$service")
+
+ if ! "${COMPOSE[@]}" up -d --no-deps --wait \
+ --wait-timeout "$DEPLOY_WAIT_TIMEOUT_SECONDS" "$service"; then
+ echo "$service deployment failed. Recent logs:" >&2
+ "${COMPOSE[@]}" logs --tail=120 "$service" >&2 || true
+ rollback_services "${attempted_services[@]}" || \
+ echo "Automatic rollback was incomplete; manual intervention is required." >&2
+ print_retry_hint
+ exit 1
+ fi
+
+ # On a bootstrap deployment, nginx cannot start until both application
+ # services exist because its config resolves both upstream names. Existing
+ # nginx containers are still reloaded after each selected replacement.
+ if [ "$service_index" -lt "$last_service_index" ] \
+ && [ -z "$("${COMPOSE[@]}" ps --status running -q nginx)" ]; then
+ echo "nginx is not running; deferring startup until selected services are ready."
+ continue
+ fi
+
+ if ! reload_nginx COMPOSE; then
+ echo "Failed to reload nginx after the $service deployment. Recent logs:" >&2
+ "${COMPOSE[@]}" logs --tail=120 "$service" nginx >&2 || true
+ rollback_services "${attempted_services[@]}" || \
+ echo "Automatic rollback was incomplete; manual intervention is required." >&2
+ print_retry_hint
+ exit 1
+ fi
+done
+
+echo "Deployment complete: $resolved_target now uses letscube-app:$current_commit"
"${COMPOSE[@]}" ps
diff --git a/scripts/test-deploy-classifier.sh b/scripts/test-deploy-classifier.sh
new file mode 100755
index 0000000..70368b2
--- /dev/null
+++ b/scripts/test-deploy-classifier.sh
@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+CLASSIFIER="$SCRIPT_DIR/classify-deploy-target.sh"
+
+assert_target() {
+ local expected="$1"
+ shift
+
+ local actual
+ actual="$(bash "$CLASSIFIER" "$@")"
+ if [ "$actual" != "$expected" ]; then
+ echo "Expected '$expected' for: $*" >&2
+ echo "Got '$actual'" >&2
+ exit 1
+ fi
+}
+
+assert_target none
+assert_target none README.md docs/operations.md .github/workflows/ci.yml
+assert_target api client/src/App.jsx client/src/styles.css
+assert_target all client/src/lib/protocol.js
+assert_target all client/src/lib/protocol.json
+assert_target all client/src/lib/events.json
+assert_target all server/socket/index.js
+assert_target all yarn.lock
+assert_target all client/src/App.jsx compose.yml
+assert_target all unknown-runtime-file
+
+echo "Deploy target classifier checks passed."
diff --git a/scripts/test-deploy.sh b/scripts/test-deploy.sh
new file mode 100755
index 0000000..ef82299
--- /dev/null
+++ b/scripts/test-deploy.sh
@@ -0,0 +1,275 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+log_command() {
+ local command="$1"
+ shift
+
+ {
+ printf '%s' "$command"
+ printf ' %q' "$@"
+ printf '\n'
+ } >> "$DEPLOY_TEST_LOG"
+}
+
+fake_git() {
+ log_command git "$@"
+
+ case "$1" in
+ rev-parse)
+ if [ -e "$FAKE_GIT_STATE" ]; then
+ printf '%s\n' "$FAKE_CURRENT_COMMIT"
+ else
+ : > "$FAKE_GIT_STATE"
+ printf '%s\n' "$FAKE_PREVIOUS_COMMIT"
+ fi
+ ;;
+ fetch|merge)
+ ;;
+ diff)
+ if [ -n "${FAKE_CHANGED_PATH:-}" ]; then
+ printf '%s\0' "$FAKE_CHANGED_PATH"
+ fi
+ ;;
+ show)
+ printf '%s\n' \
+ 'name: letscube' \
+ 'services:' \
+ ' api:' \
+ ' image: letscube-app:${APP_IMAGE_TAG:-local}' \
+ ' socket:' \
+ ' image: letscube-app:${APP_IMAGE_TAG:-local}'
+ ;;
+ *)
+ echo "Unexpected fake git command: $*" >&2
+ return 1
+ ;;
+ esac
+}
+
+fake_docker() {
+ log_command docker "$@"
+
+ case "$1" in
+ inspect)
+ printf 'sha256:old-image\n'
+ return 0
+ ;;
+ image)
+ return 0
+ ;;
+ compose)
+ shift
+ ;;
+ *)
+ echo "Unexpected fake docker command: $*" >&2
+ return 1
+ ;;
+ esac
+
+ local args=("$@")
+ local subcommand=""
+ local service="${args[${#args[@]} - 1]}"
+ local is_rollback="false"
+ local arg
+
+ for arg in "${args[@]}"; do
+ case "$arg" in
+ build|exec|logs|ps|run|up)
+ if [ -z "$subcommand" ]; then
+ subcommand="$arg"
+ fi
+ ;;
+ esac
+ if [[ "$arg" == */letscube-deploy.*/* ]]; then
+ is_rollback="true"
+ fi
+ done
+
+ if [ "$subcommand" = "ps" ]; then
+ case "$service" in
+ api)
+ [ -z "${FAKE_RUNNING_API:-}" ] || printf '%s\n' "$FAKE_RUNNING_API"
+ ;;
+ socket)
+ [ -z "${FAKE_RUNNING_SOCKET:-}" ] || printf '%s\n' "$FAKE_RUNNING_SOCKET"
+ ;;
+ nginx)
+ [ -z "${FAKE_RUNNING_NGINX:-}" ] || printf '%s\n' "$FAKE_RUNNING_NGINX"
+ ;;
+ esac
+ return 0
+ fi
+
+ if [ "$subcommand" = "up" ] && [ "$service" = "socket" ]; then
+ if [ "$is_rollback" = "true" ]; then
+ {
+ printf 'APP_IMAGE_TAG=%s\n' "${APP_IMAGE_TAG:-}"
+ printf '%s\n' "${args[*]}"
+ } > "$FAKE_ROLLBACK_MARKER"
+ elif [ "${FAKE_FAIL_CURRENT_SOCKET:-false}" = "true" ]; then
+ return 1
+ fi
+ fi
+}
+
+case "$(basename -- "$0")" in
+ git)
+ fake_git "$@"
+ exit
+ ;;
+ docker)
+ fake_docker "$@"
+ exit
+ ;;
+esac
+
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+DEPLOY_SCRIPT="$SCRIPT_DIR/deploy.sh"
+CLASSIFIER_SCRIPT="$SCRIPT_DIR/classify-deploy-target.sh"
+TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/letscube-deploy-test.XXXXXX")"
+
+cleanup() {
+ rm -rf -- "$TEST_ROOT"
+}
+
+trap cleanup EXIT
+
+FAKE_BIN="$TEST_ROOT/bin"
+mkdir -p "$FAKE_BIN"
+ln -s "$SCRIPT_DIR/test-deploy.sh" "$FAKE_BIN/git"
+ln -s "$SCRIPT_DIR/test-deploy.sh" "$FAKE_BIN/docker"
+
+prepare_app() {
+ local app_dir="$1"
+
+ mkdir -p "$app_dir/scripts"
+ cp "$DEPLOY_SCRIPT" "$app_dir/scripts/deploy.sh"
+ cp "$CLASSIFIER_SCRIPT" "$app_dir/scripts/classify-deploy-target.sh"
+}
+
+assert_contains() {
+ local value="$1"
+ local expected="$2"
+
+ if [[ "$value" != *"$expected"* ]]; then
+ echo "Expected output to contain: $expected" >&2
+ exit 1
+ fi
+}
+
+command_line() {
+ local file="$1"
+ local expected="$2"
+ local number=0
+ local line
+
+ while IFS= read -r line; do
+ number=$((number + 1))
+ if [[ "$line" == *"$expected"* ]]; then
+ printf '%s\n' "$number"
+ return 0
+ fi
+ done < "$file"
+
+ echo "Missing command containing: $expected" >&2
+ return 1
+}
+
+run_bootstrap_test() {
+ local scenario="$TEST_ROOT/bootstrap"
+ local app_dir="$scenario/app"
+ local log="$scenario/commands.log"
+ local output="$scenario/output.log"
+ local git_state="$scenario/git-state"
+ local rollback_marker="$scenario/rollback"
+ local commit="1111111111111111111111111111111111111111"
+ local backing_line
+ local migration_line
+ local socket_line
+ local api_line
+
+ mkdir -p "$scenario/tmp"
+ prepare_app "$app_dir"
+
+ PATH="$FAKE_BIN:$PATH" \
+ APP_DIR="$app_dir" \
+ DEPLOY_TEST_LOG="$log" \
+ DEPLOY_WAIT_TIMEOUT_SECONDS=17 \
+ ENV_FILE=.env.prod \
+ FAKE_CURRENT_COMMIT="$commit" \
+ FAKE_GIT_STATE="$git_state" \
+ FAKE_PREVIOUS_COMMIT="$commit" \
+ FAKE_ROLLBACK_MARKER="$rollback_marker" \
+ TMPDIR="$scenario/tmp" \
+ bash "$app_dir/scripts/deploy.sh" > "$output" 2>&1
+
+ assert_contains "$(<"$output")" 'Missing runtime services: api socket nginx; deploying all.'
+ assert_contains "$(<"$output")" '(target: all)'
+
+ backing_line="$(command_line "$log" 'up -d --no-recreate --wait --wait-timeout 17 mongo postgres redis')"
+ migration_line="$(command_line "$log" 'run --rm --no-deps migrate')"
+ socket_line="$(command_line "$log" 'up -d --no-deps --wait --wait-timeout 17 socket')"
+ api_line="$(command_line "$log" 'up -d --no-deps --wait --wait-timeout 17 api')"
+
+ if [ "$backing_line" -ge "$migration_line" ]; then
+ echo 'Migrations ran before backing services became ready.' >&2
+ exit 1
+ fi
+ if [ "$socket_line" -ge "$api_line" ]; then
+ echo 'Bootstrap did not deploy socket before API.' >&2
+ exit 1
+ fi
+}
+
+run_rollback_test() {
+ local scenario="$TEST_ROOT/rollback"
+ local app_dir="$scenario/app"
+ local log="$scenario/commands.log"
+ local output="$scenario/output.log"
+ local git_state="$scenario/git-state"
+ local rollback_marker="$scenario/rollback"
+ local previous_commit="1111111111111111111111111111111111111111"
+ local current_commit="2222222222222222222222222222222222222222"
+ local rollback_details
+
+ mkdir -p "$scenario/tmp"
+ prepare_app "$app_dir"
+
+ if PATH="$FAKE_BIN:$PATH" \
+ APP_DIR="$app_dir" \
+ DEPLOY_TEST_LOG="$log" \
+ DEPLOY_WAIT_TIMEOUT_SECONDS=17 \
+ ENV_FILE=.env.prod \
+ FAKE_CHANGED_PATH=server/index.js \
+ FAKE_CURRENT_COMMIT="$current_commit" \
+ FAKE_FAIL_CURRENT_SOCKET=true \
+ FAKE_GIT_STATE="$git_state" \
+ FAKE_PREVIOUS_COMMIT="$previous_commit" \
+ FAKE_ROLLBACK_MARKER="$rollback_marker" \
+ FAKE_RUNNING_API=api-container \
+ FAKE_RUNNING_NGINX=nginx-container \
+ FAKE_RUNNING_SOCKET=socket-container \
+ TMPDIR="$scenario/tmp" \
+ bash "$app_dir/scripts/deploy.sh" > "$output" 2>&1; then
+ echo 'Expected the simulated socket deployment to fail.' >&2
+ exit 1
+ fi
+
+ if [ ! -f "$rollback_marker" ]; then
+ echo 'Rollback did not recreate the socket service.' >&2
+ exit 1
+ fi
+
+ rollback_details="$(<"$rollback_marker")"
+ assert_contains "$rollback_details" "--project-directory $app_dir"
+ assert_contains "$rollback_details" '/letscube-deploy.'
+ assert_contains "$rollback_details" 'APP_IMAGE_TAG=rollback-socket-111111111111-'
+ assert_contains "$(<"$log")" "git show $previous_commit:compose.yml"
+ assert_contains "$(<"$log")" "git show $previous_commit:compose.prod.yml"
+}
+
+run_bootstrap_test
+run_rollback_test
+
+echo 'Deploy script checks passed.'
diff --git a/server/auth/index.js b/server/auth/index.js
index e2613d9..91219c9 100644
--- a/server/auth/index.js
+++ b/server/auth/index.js
@@ -2,6 +2,7 @@ const express = require('express');
const CustomStrategy = require('passport-custom').Strategy;
const { URLSearchParams } = require('url');
const { User } = require('../models');
+const metrics = require('../metrics');
const checkStatus = async (res) => {
if (res.ok) { // res.status >= 200 && res.status < 300
@@ -20,10 +21,11 @@ module.exports = (app, passport) => {
const { code, redirectUri } = req.body;
if (!code) {
- done(new Error({
- status: 400,
- message: 'Invalid code passed',
- }));
+ const err = new Error('Invalid code passed');
+ Object.defineProperty(err, 'authFailureReason', {
+ value: 'missing_code',
+ });
+ done(err);
return;
}
@@ -118,19 +120,24 @@ module.exports = (app, passport) => {
router.post('/code',
(req, res, next) => {
- passport.authenticate('custom', (err, user) => {
+ passport.authenticate('custom', async (err, user) => {
if (err) {
+ await metrics.recordAuthFailure(
+ err.authFailureReason || 'provider_or_persistence_error',
+ );
return res.status(500).json(err);
}
if (!user) {
+ await metrics.recordAuthFailure('no_user');
return res.status(401).json({
message: 'Authentication failed',
});
}
- req.login(user, (loginErr) => {
+ req.login(user, async (loginErr) => {
if (loginErr) {
+ await metrics.recordAuthFailure('session_login_error');
return res.json(loginErr);
}
diff --git a/server/config/default.json b/server/config/default.json
index e4a27b9..463f612 100644
--- a/server/config/default.json
+++ b/server/config/default.json
@@ -3,9 +3,17 @@
"port": 8080
},
"socketio": {
- "port": 9000
+ "port": 9000,
+ "reconnectGraceMs": 60000
},
"mongodb": "mongodb://127.0.0.1/letscube",
+ "postgres": {
+ "host": "127.0.0.1",
+ "port": 5432,
+ "database": "letscube",
+ "user": "letscube",
+ "password": "letscube"
+ },
"wcaSource": "https://staging.worldcubeassociation.org",
"auth": {
"secret": "",
diff --git a/server/config/dev.json b/server/config/dev.json
index 18ea264..115e325 100644
--- a/server/config/dev.json
+++ b/server/config/dev.json
@@ -7,6 +7,13 @@
"port": 9000
},
"mongodb": "mongodb://127.0.0.1/letscube",
+ "postgres": {
+ "host": "127.0.0.1",
+ "port": 55433,
+ "database": "letscube",
+ "user": "letscube",
+ "password": "letscube"
+ },
"wcaSource": "https://staging.worldcubeassociation.org",
"auth": {
"secret": "dev-secret",
@@ -17,4 +24,4 @@
"cors": {
"origin": ["localhost", "192.168.1.18"]
}
-}
\ No newline at end of file
+}
diff --git a/server/health.js b/server/health.js
new file mode 100644
index 0000000..e10c3a8
--- /dev/null
+++ b/server/health.js
@@ -0,0 +1,72 @@
+const runCheck = async (check, timeoutMs) => {
+ let timer;
+ const timeout = new Promise((resolve) => {
+ timer = setTimeout(() => resolve(false), timeoutMs);
+ if (timer.unref) {
+ timer.unref();
+ }
+ });
+
+ const result = await Promise.race([
+ Promise.resolve()
+ .then(check)
+ .then((healthy) => healthy !== false)
+ .catch(() => false),
+ timeout,
+ ]);
+
+ clearTimeout(timer);
+ return result;
+};
+
+const createHealthReporter = ({
+ service,
+ checks,
+ checkTimeoutMs = 2000,
+ now = () => new Date(),
+ uptime = () => process.uptime(),
+}) => async () => {
+ const checkResults = await Promise.all(Object.entries(checks).map(async ([name, value]) => {
+ const descriptor = typeof value === 'function' ? { check: value } : value;
+ const required = descriptor.required !== false;
+ const status = await runCheck(descriptor.check, checkTimeoutMs) ? 'ok' : 'error';
+ return { name, required, status };
+ }));
+ const normalizedChecks = Object.fromEntries(checkResults.map(({ name, status }) => (
+ [name, status]
+ )));
+ const requiredCheckFailed = checkResults.some(({ required, status }) => (
+ required && status === 'error'
+ ));
+ const optionalCheckFailed = checkResults.some(({ required, status }) => (
+ !required && status === 'error'
+ ));
+ let status = 'ok';
+ if (requiredCheckFailed) {
+ status = 'error';
+ } else if (optionalCheckFailed) {
+ status = 'degraded';
+ }
+
+ return {
+ status,
+ service,
+ timestamp: now().toISOString(),
+ uptimeSeconds: Math.floor(uptime()),
+ checks: normalizedChecks,
+ };
+};
+
+const createHealthHandler = (reportHealth) => async (_req, res) => {
+ const report = await reportHealth();
+
+ res.statusCode = report.status === 'error' ? 503 : 200;
+ res.setHeader('Cache-Control', 'no-store');
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ res.end(JSON.stringify(report));
+};
+
+module.exports = {
+ createHealthHandler,
+ createHealthReporter,
+};
diff --git a/server/health.test.js b/server/health.test.js
new file mode 100644
index 0000000..48880b2
--- /dev/null
+++ b/server/health.test.js
@@ -0,0 +1,148 @@
+/* eslint-env jest */
+
+const { createHealthHandler, createHealthReporter } = require('./health');
+
+describe('health reporting', () => {
+ const fixedTime = new Date('2026-07-10T12:00:00.000Z');
+
+ it('reports healthy dependencies', async () => {
+ const reportHealth = createHealthReporter({
+ service: 'api',
+ checks: {
+ mongodb: () => true,
+ postgres: async () => true,
+ },
+ now: () => fixedTime,
+ uptime: () => 12.9,
+ });
+
+ await expect(reportHealth()).resolves.toEqual({
+ status: 'ok',
+ service: 'api',
+ timestamp: fixedTime.toISOString(),
+ uptimeSeconds: 12,
+ checks: {
+ mongodb: 'ok',
+ postgres: 'ok',
+ },
+ });
+ });
+
+ it('reports failed and timed-out dependencies without exposing errors', async () => {
+ const reportHealth = createHealthReporter({
+ service: 'socket',
+ checks: {
+ mongodb: () => {
+ throw new Error('connection details');
+ },
+ redis: () => new Promise(() => {}),
+ },
+ checkTimeoutMs: 1,
+ now: () => fixedTime,
+ uptime: () => 3,
+ });
+
+ await expect(reportHealth()).resolves.toEqual({
+ status: 'error',
+ service: 'socket',
+ timestamp: fixedTime.toISOString(),
+ uptimeSeconds: 3,
+ checks: {
+ mongodb: 'error',
+ redis: 'error',
+ },
+ });
+ });
+
+ it('reports degraded when only an optional dependency fails', async () => {
+ const reportHealth = createHealthReporter({
+ service: 'socket',
+ checks: {
+ mongodb: () => true,
+ postgres: {
+ required: false,
+ check: () => false,
+ },
+ redis: () => true,
+ },
+ now: () => fixedTime,
+ uptime: () => 5,
+ });
+
+ await expect(reportHealth()).resolves.toEqual({
+ status: 'degraded',
+ service: 'socket',
+ timestamp: fixedTime.toISOString(),
+ uptimeSeconds: 5,
+ checks: {
+ mongodb: 'ok',
+ postgres: 'error',
+ redis: 'ok',
+ },
+ });
+ });
+
+ it('reports error when a required dependency fails alongside an optional one', async () => {
+ const reportHealth = createHealthReporter({
+ service: 'socket',
+ checks: {
+ mongodb: () => false,
+ postgres: {
+ required: false,
+ check: () => false,
+ },
+ },
+ now: () => fixedTime,
+ uptime: () => 5,
+ });
+
+ await expect(reportHealth()).resolves.toMatchObject({
+ status: 'error',
+ checks: {
+ mongodb: 'error',
+ postgres: 'error',
+ },
+ });
+ });
+
+ it('returns 503 and disables caching when a health check fails', async () => {
+ const report = {
+ status: 'error',
+ service: 'api',
+ checks: { mongodb: 'error' },
+ };
+ const response = {
+ setHeader: jest.fn(),
+ end: jest.fn(),
+ };
+ const handler = createHealthHandler(jest.fn().mockResolvedValue(report));
+
+ await handler({}, response);
+
+ expect(response.statusCode).toBe(503);
+ expect(response.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store');
+ expect(response.setHeader).toHaveBeenCalledWith(
+ 'Content-Type',
+ 'application/json; charset=utf-8',
+ );
+ expect(response.end).toHaveBeenCalledWith(JSON.stringify(report));
+ });
+
+ it('returns 200 for degraded health', async () => {
+ const report = {
+ status: 'degraded',
+ service: 'api',
+ checks: { postgres: 'error' },
+ };
+ const response = {
+ setHeader: jest.fn(),
+ end: jest.fn(),
+ };
+ const handler = createHealthHandler(jest.fn().mockResolvedValue(report));
+
+ await handler({}, response);
+
+ expect(response.statusCode).toBe(200);
+ expect(response.end).toHaveBeenCalledWith(JSON.stringify(report));
+ });
+});
diff --git a/server/index.js b/server/index.js
index 4a85e6e..95d74fa 100644
--- a/server/index.js
+++ b/server/index.js
@@ -7,11 +7,12 @@ const passport = require('passport');
const config = require('./runtimeConfig');
const { connect } = require('./database');
+const { createHealthHandler, createHealthReporter } = require('./health');
+const { initializePostgres, pool, startPostgresMaintenance } = require('./postgres');
const session = require('./middlewares/session');
const logger = require('./logger');
const auth = require('./auth');
const api = require('./api');
-const { Room } = require('./models');
Error.stackTraceLimit = 100;
@@ -25,6 +26,26 @@ const init = async () => {
app.use(express.urlencoded({ extended: true })); // for parsing application/x-www-form-urlencoded
const mongoose = await connect();
+ await initializePostgres();
+ startPostgresMaintenance();
+
+ const reportHealth = createHealthReporter({
+ service: 'api',
+ checks: {
+ mongodb: () => mongoose.connection.readyState === 1,
+ postgres: {
+ required: false,
+ check: async () => {
+ if (config.postgres.enabled) {
+ await pool.query('SELECT 1');
+ }
+ return true;
+ },
+ },
+ },
+ });
+
+ app.get('/health/api', createHealthHandler(reportHealth));
/* Logging */
@@ -98,26 +119,3 @@ try {
} catch (e) {
logger.error(e);
}
-
-/* eslint-disable no-await-in-loop */
-async function asyncForEach(array, callback) {
- for (let index = 0; index < array.length; index += 1) {
- await callback(array[index], index, array);
- }
-}
-
-process.on('SIGINT', () => {
- Room.find().then(async (rooms) => {
- try {
- await asyncForEach(rooms, async (room) => {
- await asyncForEach(room.users, async (user) => {
- await room.dropUser(user);
- });
- });
- } catch (e) {
- logger.error(e);
- }
-
- process.exit(0);
- });
-});
diff --git a/server/metrics.js b/server/metrics.js
new file mode 100644
index 0000000..f1fb7ce
--- /dev/null
+++ b/server/metrics.js
@@ -0,0 +1,227 @@
+const crypto = require('crypto');
+const { v4: uuidv4 } = require('uuid');
+
+const config = require('./runtimeConfig');
+const logger = require('./logger');
+const { METRIC_EVENTS, MetricEvent } = require('./models');
+const { mirrorMetricEvent } = require('./postgres/dualWrite');
+
+const MS_PER_DAY = 24 * 60 * 60 * 1000;
+
+const countRoomSolves = (room) => room.attempts.reduce((total, attempt) => {
+ const resultCount = attempt.results instanceof Map
+ ? attempt.results.size
+ : Object.keys(attempt.results || {}).length;
+ return total + resultCount;
+}, 0);
+
+const createMetricRecorder = ({
+ model = MetricEvent,
+ metricsConfig = config.metrics,
+ metricLogger = logger,
+ metricMirror = mirrorMetricEvent,
+ now = () => new Date(),
+} = {}) => {
+ const enabled = !!metricsConfig.enabled;
+ const retentionMs = metricsConfig.retentionDays * MS_PER_DAY;
+
+ const pseudonymize = (kind, value) => {
+ if (value === undefined || value === null || !metricsConfig.hashSecret) {
+ return undefined;
+ }
+
+ return crypto.createHmac('sha256', metricsConfig.hashSecret)
+ .update(`${kind}:${value}`)
+ .digest('hex');
+ };
+
+ const roomProperties = (room) => {
+ if (!room) {
+ return {};
+ }
+
+ return {
+ roomId: pseudonymize('room', room._id || room.id),
+ roomType: room.type,
+ cubeEvent: room.event,
+ privateRoom: !!room.password,
+ };
+ };
+
+ const actorProperties = ({ userId, connectionId }) => ({
+ actorId: pseudonymize(userId ? 'user' : 'connection', userId || connectionId),
+ actorType: userId ? 'authenticated' : 'anonymous',
+ });
+
+ const write = async (event, { ignoreDuplicate = false } = {}) => {
+ if (!enabled) {
+ return null;
+ }
+
+ const occurredAt = now();
+ const persistedEvent = {
+ ...event,
+ eventId: uuidv4(),
+ occurredAt,
+ expiresAt: new Date(occurredAt.getTime() + retentionMs),
+ };
+ let created = null;
+ try {
+ created = await model.create(persistedEvent);
+ } catch (err) {
+ if (!ignoreDuplicate || err.code !== 11000) {
+ metricLogger.error(err);
+ }
+ if (ignoreDuplicate && err.code === 11000) {
+ return null;
+ }
+ }
+
+ Promise.resolve()
+ .then(() => metricMirror(persistedEvent))
+ .catch((err) => metricLogger.error(err));
+ return created;
+ };
+
+ const beginRoomVisit = async ({
+ room, userId, connectionId, activeUserCount, replaceActive = false,
+ }) => {
+ if (!enabled) {
+ return null;
+ }
+
+ const event = {
+ event: METRIC_EVENTS.ROOM_JOINED,
+ ...actorProperties({ userId, connectionId }),
+ ...roomProperties(room),
+ activeUserCount,
+ active: true,
+ };
+
+ if (replaceActive && event.actorId && event.roomId) {
+ try {
+ await model.updateMany({
+ event: METRIC_EVENTS.ROOM_JOINED,
+ actorId: event.actorId,
+ roomId: event.roomId,
+ active: true,
+ }, {
+ $set: {
+ active: false,
+ closedAt: now(),
+ },
+ });
+ } catch (err) {
+ metricLogger.error(err);
+ }
+ }
+
+ // An authenticated user may join through multiple tabs. The partial unique
+ // index turns those sockets into one logical room visit.
+ const created = await write(event, { ignoreDuplicate: true });
+ if (created || !event.actorId || !event.roomId) {
+ return created;
+ }
+
+ try {
+ return await model.findOne({
+ event: METRIC_EVENTS.ROOM_JOINED,
+ actorId: event.actorId,
+ roomId: event.roomId,
+ active: true,
+ });
+ } catch (err) {
+ metricLogger.error(err);
+ return null;
+ }
+ };
+
+ const endRoomVisit = async ({
+ room, userId, connectionId, leaveReason, activeUserCount,
+ }) => {
+ if (!enabled) {
+ return null;
+ }
+
+ const actor = actorProperties({ userId, connectionId });
+ const roomData = roomProperties(room);
+ const closedAt = now();
+ let durationMs;
+ let visitFound = false;
+
+ if (actor.actorId && roomData.roomId) {
+ try {
+ const visit = await model.findOneAndUpdate({
+ event: METRIC_EVENTS.ROOM_JOINED,
+ actorId: actor.actorId,
+ roomId: roomData.roomId,
+ active: true,
+ }, {
+ $set: {
+ active: false,
+ closedAt,
+ },
+ }, {
+ sort: { occurredAt: -1 },
+ });
+
+ if (visit) {
+ visitFound = true;
+ durationMs = Math.max(0, closedAt.getTime() - visit.occurredAt.getTime());
+ }
+ } catch (err) {
+ metricLogger.error(err);
+ }
+ }
+
+ // A kick or ban is followed by the affected socket disconnecting. Only the
+ // first path that closes the active visit should emit a leave event.
+ if (actor.actorId && roomData.roomId && !visitFound) {
+ return null;
+ }
+
+ return write({
+ event: METRIC_EVENTS.ROOM_LEFT,
+ ...actor,
+ ...roomData,
+ leaveReason,
+ activeUserCount,
+ durationMs,
+ });
+ };
+
+ return {
+ recordAuthFailure: (failureReason) => write({
+ event: METRIC_EVENTS.AUTH_FAILED,
+ failureReason,
+ }),
+ recordRoomCreated: ({ room, userId }) => write({
+ event: METRIC_EVENTS.ROOM_CREATED,
+ ...actorProperties({ userId }),
+ ...roomProperties(room),
+ }),
+ recordRoomJoinFailure: ({
+ room, userId, connectionId, failureReason,
+ }) => write({
+ event: METRIC_EVENTS.ROOM_JOIN_FAILED,
+ ...actorProperties({ userId, connectionId }),
+ ...roomProperties(room),
+ failureReason,
+ }),
+ recordRoomResult: ({ room, userId }) => write({
+ event: METRIC_EVENTS.ROOM_RESULT_SUBMITTED,
+ ...actorProperties({ userId }),
+ ...roomProperties(room),
+ roomSolveCount: countRoomSolves(room),
+ }),
+ beginRoomVisit,
+ endRoomVisit,
+ pseudonymize,
+ };
+};
+
+module.exports = {
+ ...createMetricRecorder(),
+ countRoomSolves,
+ createMetricRecorder,
+};
diff --git a/server/metrics.test.js b/server/metrics.test.js
new file mode 100644
index 0000000..c380e1b
--- /dev/null
+++ b/server/metrics.test.js
@@ -0,0 +1,192 @@
+/** @jest-environment node */
+/* eslint-env jest */
+
+const { METRIC_EVENTS } = require('./models/metricEvent');
+const { countRoomSolves, createMetricRecorder } = require('./metrics');
+
+const room = {
+ _id: 'room-123',
+ type: 'normal',
+ event: '333',
+ password: 'hashed-password',
+ attempts: [
+ { results: new Map([['1', {}], ['2', {}]]) },
+ { results: new Map([['1', {}]]) },
+ ],
+};
+
+const createRecorder = (overrides = {}) => {
+ const model = {
+ create: jest.fn(async (event) => event),
+ findOne: jest.fn(),
+ findOneAndUpdate: jest.fn(),
+ updateMany: jest.fn(),
+ };
+ const metricLogger = { error: jest.fn() };
+ const metricMirror = jest.fn();
+ const now = jest.fn(() => new Date('2026-07-09T12:00:00.000Z'));
+ const recorder = createMetricRecorder({
+ model,
+ metricLogger,
+ metricMirror,
+ now,
+ metricsConfig: {
+ enabled: true,
+ hashSecret: 'metrics-test-secret',
+ retentionDays: 90,
+ },
+ ...overrides,
+ });
+
+ return {
+ model, metricLogger, metricMirror, now, recorder,
+ };
+};
+
+describe('metrics recorder', () => {
+ it('pseudonymizes users and rooms with separated identifiers', () => {
+ const { recorder } = createRecorder();
+
+ const userId = recorder.pseudonymize('user', 123);
+ const roomId = recorder.pseudonymize('room', 123);
+
+ expect(userId).toHaveLength(64);
+ expect(userId).not.toBe('123');
+ expect(userId).not.toBe(roomId);
+ expect(recorder.pseudonymize('user', 123)).toBe(userId);
+ });
+
+ it('records one active room visit without raw identifiers or room names', async () => {
+ const { model, recorder } = createRecorder();
+
+ await recorder.beginRoomVisit({
+ room,
+ userId: 123,
+ connectionId: 'socket-secret',
+ activeUserCount: 4,
+ });
+
+ expect(model.create).toHaveBeenCalledWith(expect.objectContaining({
+ event: METRIC_EVENTS.ROOM_JOINED,
+ actorType: 'authenticated',
+ activeUserCount: 4,
+ roomType: 'normal',
+ cubeEvent: '333',
+ privateRoom: true,
+ active: true,
+ occurredAt: new Date('2026-07-09T12:00:00.000Z'),
+ expiresAt: new Date('2026-10-07T12:00:00.000Z'),
+ }));
+
+ const event = model.create.mock.calls[0][0];
+ expect(event.actorId).not.toContain('123');
+ expect(event.roomId).not.toContain('room-123');
+ expect(event).not.toHaveProperty('connectionId');
+ expect(event).not.toHaveProperty('name');
+ expect(event).not.toHaveProperty('password');
+ });
+
+ it('closes a stale visit when the room state says this is a new join', async () => {
+ const { model, recorder } = createRecorder();
+
+ await recorder.beginRoomVisit({
+ room,
+ userId: 123,
+ activeUserCount: 4,
+ replaceActive: true,
+ });
+
+ expect(model.updateMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: METRIC_EVENTS.ROOM_JOINED,
+ active: true,
+ }),
+ { $set: expect.objectContaining({ active: false }) },
+ );
+ expect(model.create).toHaveBeenCalledTimes(1);
+ });
+
+ it('records a leave with the duration of the open visit', async () => {
+ const joinedAt = new Date('2026-07-09T11:55:00.000Z');
+ const { model, recorder } = createRecorder();
+ model.findOneAndUpdate.mockResolvedValue({ occurredAt: joinedAt });
+
+ await recorder.endRoomVisit({
+ room,
+ userId: 123,
+ leaveReason: 'explicit',
+ activeUserCount: 3,
+ });
+
+ expect(model.findOneAndUpdate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: METRIC_EVENTS.ROOM_JOINED,
+ active: true,
+ }),
+ { $set: expect.objectContaining({ active: false }) },
+ { sort: { occurredAt: -1 } },
+ );
+ expect(model.create).toHaveBeenCalledWith(expect.objectContaining({
+ event: METRIC_EVENTS.ROOM_LEFT,
+ leaveReason: 'explicit',
+ durationMs: 5 * 60 * 1000,
+ activeUserCount: 3,
+ }));
+ });
+
+ it('does not duplicate a leave after a visit has already closed', async () => {
+ const { model, recorder } = createRecorder();
+ model.findOneAndUpdate.mockResolvedValue(null);
+
+ await expect(recorder.endRoomVisit({
+ room,
+ userId: 123,
+ leaveReason: 'disconnect',
+ activeUserCount: 3,
+ })).resolves.toBeNull();
+
+ expect(model.create).not.toHaveBeenCalled();
+ });
+
+ it('records join and authentication failure categories only', async () => {
+ const { model, recorder } = createRecorder();
+
+ await recorder.recordRoomJoinFailure({
+ room,
+ userId: 123,
+ failureReason: 'invalid_password',
+ });
+ await recorder.recordAuthFailure('missing_code');
+
+ expect(model.create).toHaveBeenNthCalledWith(1, expect.objectContaining({
+ event: METRIC_EVENTS.ROOM_JOIN_FAILED,
+ failureReason: 'invalid_password',
+ }));
+ expect(model.create).toHaveBeenNthCalledWith(2, expect.objectContaining({
+ event: METRIC_EVENTS.AUTH_FAILED,
+ failureReason: 'missing_code',
+ }));
+ });
+
+ it('counts unique result slots without storing solve times', async () => {
+ const { model, recorder } = createRecorder();
+
+ expect(countRoomSolves(room)).toBe(3);
+ await recorder.recordRoomResult({ room, userId: 123 });
+
+ expect(model.create).toHaveBeenCalledWith(expect.objectContaining({
+ event: METRIC_EVENTS.ROOM_RESULT_SUBMITTED,
+ roomSolveCount: 3,
+ }));
+ expect(model.create.mock.calls[0][0]).not.toHaveProperty('attempts');
+ });
+
+ it('does not disrupt application behavior when metrics storage fails', async () => {
+ const { model, metricLogger, recorder } = createRecorder();
+ const error = new Error('metrics database unavailable');
+ model.create.mockRejectedValue(error);
+
+ await expect(recorder.recordAuthFailure('no_user')).resolves.toBeNull();
+ expect(metricLogger.error).toHaveBeenCalledWith(error);
+ });
+});
diff --git a/server/models/index.js b/server/models/index.js
index 86f1922..8e5ba12 100644
--- a/server/models/index.js
+++ b/server/models/index.js
@@ -1,9 +1,12 @@
const mongoose = require('mongoose');
const { Attempt, Room } = require('./room');
+const { METRIC_EVENTS, MetricEvent } = require('./metricEvent');
const User = require('./user');
module.exports = {
Room: mongoose.model('Room', Room, 'rooms'),
User: mongoose.model('User', User, 'users'),
Attempt: mongoose.model('Attempt', Attempt, 'attempts'),
+ MetricEvent: mongoose.model('MetricEvent', MetricEvent, 'metric_events'),
+ METRIC_EVENTS,
};
diff --git a/server/models/metricEvent.js b/server/models/metricEvent.js
new file mode 100644
index 0000000..56b0a5b
--- /dev/null
+++ b/server/models/metricEvent.js
@@ -0,0 +1,112 @@
+const mongoose = require('mongoose');
+const { v4: uuidv4 } = require('uuid');
+
+const METRIC_EVENTS = Object.freeze({
+ AUTH_FAILED: 'auth_failed',
+ ROOM_CREATED: 'room_created',
+ ROOM_JOINED: 'room_joined',
+ ROOM_JOIN_FAILED: 'room_join_failed',
+ ROOM_LEFT: 'room_left',
+ ROOM_RESULT_SUBMITTED: 'room_result_submitted',
+});
+
+const MetricEvent = new mongoose.Schema({
+ eventId: {
+ type: String,
+ default: uuidv4,
+ required: true,
+ },
+ event: {
+ type: String,
+ enum: Object.values(METRIC_EVENTS),
+ required: true,
+ index: true,
+ },
+ actorId: {
+ type: String,
+ index: true,
+ },
+ actorType: {
+ type: String,
+ enum: ['authenticated', 'anonymous'],
+ },
+ roomId: {
+ type: String,
+ index: true,
+ },
+ roomType: {
+ type: String,
+ enum: ['normal', 'grand_prix'],
+ },
+ cubeEvent: String,
+ privateRoom: Boolean,
+ failureReason: String,
+ leaveReason: {
+ type: String,
+ enum: ['disconnect', 'explicit', 'kick', 'ban'],
+ },
+ activeUserCount: {
+ type: Number,
+ min: 0,
+ },
+ roomSolveCount: {
+ type: Number,
+ min: 0,
+ },
+ durationMs: {
+ type: Number,
+ min: 0,
+ },
+ active: {
+ type: Boolean,
+ default: false,
+ },
+ closedAt: Date,
+ occurredAt: {
+ type: Date,
+ default: Date.now,
+ required: true,
+ index: true,
+ },
+ expiresAt: {
+ type: Date,
+ required: true,
+ },
+}, {
+ versionKey: false,
+});
+
+MetricEvent.index({
+ expiresAt: 1,
+}, {
+ expireAfterSeconds: 0,
+});
+
+MetricEvent.index({
+ eventId: 1,
+}, {
+ unique: true,
+ partialFilterExpression: {
+ eventId: { $type: 'string' },
+ },
+});
+
+// Prevent multiple browser tabs from opening overlapping visits for one user.
+MetricEvent.index({
+ actorId: 1,
+ roomId: 1,
+ event: 1,
+}, {
+ unique: true,
+ partialFilterExpression: {
+ event: METRIC_EVENTS.ROOM_JOINED,
+ active: true,
+ actorId: { $type: 'string' },
+ roomId: { $type: 'string' },
+ },
+});
+
+module.exports = {
+ METRIC_EVENTS,
+ MetricEvent,
+};
diff --git a/server/models/room.js b/server/models/room.js
index 322d22a..366f6e8 100644
--- a/server/models/room.js
+++ b/server/models/room.js
@@ -2,7 +2,8 @@ const mongoose = require('mongoose');
const { Scrambow } = require('scrambow');
const bcrypt = require('bcrypt');
const { v4: uuidv4 } = require('uuid');
-const { Events } = require('../../client/src/lib/events');
+const Events = require('../../client/src/lib/events.json');
+const { mirrorRoomChanges } = require('../postgres/dualWrite');
const PASSWORD_SALT_ROUNDS = 10;
const STALE_ROOM_LIFETIME_MS = 10 * 60 * 1000;
@@ -17,6 +18,7 @@ const Result = new mongoose.Schema({
required: true,
},
penalties: Object,
+ submissionId: String,
}, {
timestamps: true,
});
@@ -326,5 +328,77 @@ Room.methods.updateAdminIfNeeded = function (cb) {
}
};
+const collectPostgresChanges = (room) => {
+ const changesByAttempt = new Map();
+ const participantUserIds = new Set();
+ const ensureAttempt = (attemptIndex) => {
+ if (!changesByAttempt.has(attemptIndex)) {
+ changesByAttempt.set(attemptIndex, {
+ attemptIndex,
+ resultUserIds: new Set(),
+ resultsMapModified: false,
+ syncAllResults: false,
+ });
+ }
+ return changesByAttempt.get(attemptIndex);
+ };
+
+ room.modifiedPaths({ includeChildren: true }).forEach((path) => {
+ const participantMatch = path.match(
+ /^(?:competing|waitingFor|banned|inRoom|registered)\.([^.]+)/,
+ );
+ if (participantMatch) {
+ participantUserIds.add(participantMatch[1]);
+ }
+
+ const resultMatch = path.match(/^attempts\.(\d+)\.results(?:\.([^.]+))?/);
+ if (resultMatch) {
+ const change = ensureAttempt(Number(resultMatch[1]));
+ if (resultMatch[2]) {
+ change.resultUserIds.add(resultMatch[2]);
+ } else {
+ change.resultsMapModified = true;
+ }
+ return;
+ }
+
+ const attemptMatch = path.match(/^attempts\.(\d+)\.(?:id|scrambles)(?:\.|$)/);
+ if (attemptMatch) {
+ ensureAttempt(Number(attemptMatch[1]));
+ }
+ });
+
+ (room.attempts || []).forEach((attempt, attemptIndex) => {
+ if (attempt.isNew) {
+ ensureAttempt(attemptIndex).syncAllResults = true;
+ }
+ });
+
+ const attempts = [...changesByAttempt.values()].map((change) => ({
+ attemptIndex: change.attemptIndex,
+ resultUserIds: [...change.resultUserIds],
+ syncAllResults: change.syncAllResults
+ || (change.resultsMapModified && change.resultUserIds.size === 0),
+ }));
+
+ return {
+ attempts,
+ participantUserIds: [...participantUserIds],
+ replaceAttempts: !room.isNew && room.isModified('event'),
+ syncAllParticipants: room.isNew,
+ syncRoomOwners: room.isNew || room.isModified('owner') || room.isModified('admin'),
+ };
+};
+
+Room.pre('save', function capturePostgresChanges() {
+ this.$locals.postgresChanges = collectPostgresChanges(this);
+});
+
+Room.post('save', async (room) => {
+ // PostgreSQL failures are logged and swallowed by the migration-phase writer.
+ await mirrorRoomChanges(room, room.$locals.postgresChanges);
+});
+
module.exports.Attempt = Attempt;
+module.exports.collectPostgresChanges = collectPostgresChanges;
module.exports.Room = Room;
diff --git a/server/models/room.test.js b/server/models/room.test.js
index cd844ca..131ad2b 100644
--- a/server/models/room.test.js
+++ b/server/models/room.test.js
@@ -2,7 +2,10 @@
/* eslint-env jest */
const bcrypt = require('bcrypt');
-const { Room } = require('./room');
+const mongoose = require('mongoose');
+const { collectPostgresChanges, Room } = require('./room');
+
+const RoomModel = mongoose.model('RoomPostgresChangesTest', Room);
describe('room security helpers', () => {
it('authenticates passwords asynchronously', async () => {
@@ -48,4 +51,94 @@ describe('room security helpers', () => {
expect(room.expireAt.getTime()).toBeGreaterThanOrEqual(before + 10 * 60 * 1000);
expect(room.expireAt.getTime()).toBeLessThanOrEqual(Date.now() + 10 * 60 * 1000);
});
+
+ it('stores an optional result submission id', () => {
+ const room = RoomModel.hydrate({
+ _id: '507f1f77bcf86cd799439011',
+ name: 'Practice room',
+ attempts: [{
+ id: 0,
+ scrambles: ['R U R\''],
+ results: {
+ 1234: {
+ time: 12000,
+ penalties: {},
+ submissionId: 'submission-123',
+ },
+ },
+ }],
+ });
+
+ expect(room.attempts[0].results.get('1234').submissionId).toBe('submission-123');
+ });
+
+ it('collects only the changed result for incremental PostgreSQL writes', () => {
+ const room = RoomModel.hydrate({
+ _id: '507f1f77bcf86cd799439011',
+ name: 'Practice room',
+ event: '333',
+ waitingFor: { 1234: true },
+ attempts: [{
+ _id: '507f1f77bcf86cd799439012',
+ id: 0,
+ scrambles: ['R U R\''],
+ results: {
+ 1234: {
+ _id: '507f1f77bcf86cd799439013',
+ time: 12000,
+ penalties: {},
+ },
+ },
+ }],
+ });
+ room.attempts[0].results.set('1234', {
+ time: 13000,
+ penalties: { AUF: true },
+ });
+ room.waitingFor.set('1234', false);
+
+ expect(collectPostgresChanges(room)).toEqual({
+ attempts: [{
+ attemptIndex: 0,
+ resultUserIds: ['1234'],
+ syncAllResults: false,
+ }],
+ participantUserIds: ['1234'],
+ replaceAttempts: false,
+ syncAllParticipants: false,
+ syncRoomOwners: false,
+ });
+ });
+
+ it('replaces PostgreSQL attempts when the room event resets them', () => {
+ const room = RoomModel.hydrate({
+ _id: '507f1f77bcf86cd799439011',
+ name: 'Practice room',
+ event: '333',
+ attempts: [{
+ _id: '507f1f77bcf86cd799439012',
+ id: 0,
+ scrambles: ['R U R\''],
+ results: {},
+ }],
+ });
+ room.event = '222';
+ room.attempts = [{
+ id: 0,
+ scrambles: ['R U2 R\''],
+ results: {},
+ }];
+
+ expect(collectPostgresChanges(room)).toEqual({
+ attempts: [{
+ attemptIndex: 0,
+ resultUserIds: [],
+ syncAllResults: true,
+ }],
+ participantUserIds: [],
+ replaceAttempts: true,
+ syncAllParticipants: false,
+ syncRoomOwners: false,
+ });
+ });
});
diff --git a/server/models/user.js b/server/models/user.js
index e6a6ba4..eb01155 100644
--- a/server/models/user.js
+++ b/server/models/user.js
@@ -1,4 +1,5 @@
const mongoose = require('mongoose');
+const { mirrorUser } = require('../postgres/dualWrite');
const redactUser = (doc, ret) => {
delete ret.email;
@@ -83,4 +84,12 @@ schema.virtual('canJoinRoom').get(function () {
return this.preferRealName || !!this.username;
});
+const dualWriteUser = (user) => {
+ // PostgreSQL is a non-blocking secondary during the dual-write phase.
+ mirrorUser(user);
+};
+
+schema.post('save', dualWriteUser);
+schema.post('findOneAndUpdate', dualWriteUser);
+
module.exports = schema;
diff --git a/server/package.json b/server/package.json
index 8ad5f12..e06ba46 100644
--- a/server/package.json
+++ b/server/package.json
@@ -20,6 +20,8 @@
"passport-local": "^1.0.0",
"passport-oauth2": "^1.8.0",
"passport-wca": "^1.0.3",
+ "pg": "8.22.0",
+ "prisma": "7.8.0",
"rest-api-errors": "^1.2.1",
"scrambow": "^1.8.1",
"socket.io": "^4.8.3",
@@ -31,6 +33,11 @@
"start:socket": "nodemon socket/",
"lint": "eslint ./",
"lint:fix": "eslint --fix ./",
+ "postgres:migrate": "prisma migrate deploy --config prisma.config.mjs",
+ "postgres:migrate:dev": "prisma migrate dev --config prisma.config.mjs",
+ "postgres:migrate:status": "prisma migrate status --config prisma.config.mjs",
+ "postgres:schema:check": "prisma migrate diff --exit-code --from-config-datasource --to-schema prisma/schema.prisma --config prisma.config.mjs",
+ "postgres:schema:validate": "prisma validate --config prisma.config.mjs",
"test": "jest --passWithNoTests",
"test:ci": "yarn test"
},
diff --git a/server/postgres/dualWrite.js b/server/postgres/dualWrite.js
new file mode 100644
index 0000000..7fea99b
--- /dev/null
+++ b/server/postgres/dualWrite.js
@@ -0,0 +1,557 @@
+/* eslint-disable no-await-in-loop, no-continue, no-restricted-syntax */
+const { v4: uuidv4, v5: uuidv5 } = require('uuid');
+
+const { query, withTransaction } = require('./index');
+
+const DUAL_WRITE_NAMESPACE = uuidv5('https://letscube.net/postgres-dual-write', uuidv5.URL);
+
+const stableId = (kind, value) => uuidv5(`${kind}:${value}`, DUAL_WRITE_NAMESPACE);
+
+const numericUserId = (user) => {
+ const value = typeof user === 'number' || typeof user === 'string' ? user : user && user.id;
+ const parsed = Number(value);
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
+};
+
+const sourceDate = (value, fallback = new Date()) => {
+ if (!value) {
+ return fallback;
+ }
+ return value instanceof Date ? value : new Date(value);
+};
+
+const mapValue = (map, key) => {
+ if (!map) {
+ return false;
+ }
+ if (typeof map.get === 'function') {
+ return !!map.get(key.toString());
+ }
+ return !!map[key.toString()];
+};
+
+const resultEntries = (results) => {
+ if (!results) {
+ return [];
+ }
+ if (typeof results.entries === 'function') {
+ return [...results.entries()];
+ }
+ return Object.entries(results);
+};
+
+const resultValue = (results, userId) => {
+ if (!results) {
+ return undefined;
+ }
+ if (typeof results.get === 'function') {
+ return results.get(userId.toString());
+ }
+ return results[userId.toString()];
+};
+
+const upsertUser = async (client, user, fallbackUpdatedAt = new Date()) => {
+ const wcaUserId = numericUserId(user);
+ if (!wcaUserId || !user || !user.name) {
+ return null;
+ }
+
+ const id = stableId('user', wcaUserId);
+ const updatedAt = sourceDate(user.updatedAt, fallbackUpdatedAt);
+ await client.query(`
+ INSERT INTO app.users (
+ id, wca_user_id, email, name, username, wca_id, preferences, avatar,
+ source_created_at, source_updated_at
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
+ ON CONFLICT (wca_user_id) DO UPDATE SET
+ email = EXCLUDED.email,
+ name = EXCLUDED.name,
+ username = EXCLUDED.username,
+ wca_id = EXCLUDED.wca_id,
+ preferences = EXCLUDED.preferences,
+ avatar = EXCLUDED.avatar,
+ source_updated_at = EXCLUDED.source_updated_at,
+ ingested_at = now()
+ WHERE app.users.source_updated_at < EXCLUDED.source_updated_at
+ OR (
+ app.users.source_updated_at = EXCLUDED.source_updated_at
+ AND ROW(
+ app.users.email,
+ app.users.name,
+ app.users.username,
+ app.users.wca_id,
+ app.users.preferences,
+ app.users.avatar
+ ) IS DISTINCT FROM ROW(
+ EXCLUDED.email,
+ EXCLUDED.name,
+ EXCLUDED.username,
+ EXCLUDED.wca_id,
+ EXCLUDED.preferences,
+ EXCLUDED.avatar
+ )
+ )
+ `, [
+ id,
+ wcaUserId,
+ user.email || null,
+ user.name,
+ user.username || null,
+ user.wcaId || null,
+ {
+ showWCAID: !!user.showWCAID,
+ preferRealName: !!user.preferRealName,
+ useInspection: !!user.useInspection,
+ timerType: user.timerType || 'spacebar',
+ muteTimer: !!user.muteTimer,
+ },
+ user.avatar || {},
+ user.createdAt || null,
+ updatedAt,
+ ]);
+
+ return id;
+};
+
+const mirrorUser = (user) => withTransaction((client) => upsertUser(client, user));
+
+const upsertRoomState = async (client, room, options = {}) => {
+ if (!room || !room._id) {
+ return null;
+ }
+
+ const roomId = stableId('room', room._id.toString());
+ const updatedAt = sourceDate(room.updatedAt);
+ const users = (room.users || []).filter((user) => numericUserId(user));
+ const requestedUserIds = new Set((options.userIds || []).map(numericUserId).filter(Boolean));
+ const requestedParticipantIds = new Set(
+ (options.participantUserIds || []).map(numericUserId).filter(Boolean),
+ );
+ const participantUsers = options.syncAllParticipants
+ ? users
+ : users.filter((user) => requestedParticipantIds.has(numericUserId(user)));
+ const requestedUsers = options.syncAllParticipants
+ ? users
+ : users.filter((user) => (
+ requestedUserIds.has(numericUserId(user))
+ || requestedParticipantIds.has(numericUserId(user))
+ ));
+ const roomUsers = options.syncRoomOwners || options.syncAllParticipants
+ ? [room.owner, room.admin]
+ : [];
+ const relatedUsers = new Map();
+ [...roomUsers, ...requestedUsers].forEach((user) => {
+ const wcaUserId = numericUserId(user);
+ if (wcaUserId) {
+ relatedUsers.set(wcaUserId, user);
+ }
+ });
+ const knownUserIds = new Set();
+
+ for (const user of relatedUsers.values()) {
+ const mirroredId = await upsertUser(client, user, updatedAt);
+ if (mirroredId) {
+ knownUserIds.add(numericUserId(user));
+ }
+ }
+
+ const ownerWcaId = numericUserId(room.owner);
+ const adminWcaId = numericUserId(room.admin);
+ const ownerId = ownerWcaId && knownUserIds.has(ownerWcaId)
+ ? stableId('user', ownerWcaId) : null;
+ const adminId = adminWcaId && knownUserIds.has(adminWcaId)
+ ? stableId('user', adminWcaId) : null;
+ const ownerKnown = room.owner === null || !!ownerId;
+ const adminKnown = room.admin === null || !!adminId;
+
+ await client.query(`
+ INSERT INTO app.rooms (
+ id, mongo_id, name, cube_event, access_code, password_hash, room_type,
+ owner_id, admin_id, require_revealed_identity, start_time, started,
+ next_solve_at, expires_at, twitch_channel, source_created_at, source_updated_at
+ ) VALUES (
+ $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17
+ )
+ ON CONFLICT (mongo_id) DO UPDATE SET
+ name = EXCLUDED.name,
+ cube_event = EXCLUDED.cube_event,
+ access_code = EXCLUDED.access_code,
+ password_hash = EXCLUDED.password_hash,
+ room_type = EXCLUDED.room_type,
+ owner_id = CASE WHEN $18 THEN EXCLUDED.owner_id ELSE app.rooms.owner_id END,
+ admin_id = CASE WHEN $19 THEN EXCLUDED.admin_id ELSE app.rooms.admin_id END,
+ require_revealed_identity = EXCLUDED.require_revealed_identity,
+ start_time = EXCLUDED.start_time,
+ started = EXCLUDED.started,
+ next_solve_at = EXCLUDED.next_solve_at,
+ expires_at = EXCLUDED.expires_at,
+ twitch_channel = EXCLUDED.twitch_channel,
+ deleted_at = NULL,
+ source_updated_at = EXCLUDED.source_updated_at,
+ ingested_at = now()
+ WHERE app.rooms.source_updated_at < EXCLUDED.source_updated_at
+ OR (
+ app.rooms.source_updated_at = EXCLUDED.source_updated_at
+ AND ROW(
+ app.rooms.name,
+ app.rooms.cube_event,
+ app.rooms.access_code,
+ app.rooms.password_hash,
+ app.rooms.room_type,
+ app.rooms.owner_id,
+ app.rooms.admin_id,
+ app.rooms.require_revealed_identity,
+ app.rooms.start_time,
+ app.rooms.started,
+ app.rooms.next_solve_at,
+ app.rooms.expires_at,
+ app.rooms.twitch_channel,
+ app.rooms.deleted_at
+ ) IS DISTINCT FROM ROW(
+ EXCLUDED.name,
+ EXCLUDED.cube_event,
+ EXCLUDED.access_code,
+ EXCLUDED.password_hash,
+ EXCLUDED.room_type,
+ CASE WHEN $18 THEN EXCLUDED.owner_id ELSE app.rooms.owner_id END,
+ CASE WHEN $19 THEN EXCLUDED.admin_id ELSE app.rooms.admin_id END,
+ EXCLUDED.require_revealed_identity,
+ EXCLUDED.start_time,
+ EXCLUDED.started,
+ EXCLUDED.next_solve_at,
+ EXCLUDED.expires_at,
+ EXCLUDED.twitch_channel,
+ NULL
+ )
+ )
+ `, [
+ roomId,
+ room._id.toString(),
+ room.name,
+ room.event,
+ room.accessCode,
+ room.password || null,
+ room.type,
+ ownerId,
+ adminId,
+ !!room.requireRevealedIdentity,
+ room.startTime || null,
+ !!room.started,
+ room.nextSolveAt || null,
+ room.expireAt || null,
+ room.twitchChannel || null,
+ room.createdAt || null,
+ updatedAt,
+ ownerKnown,
+ adminKnown,
+ ]);
+
+ for (const user of participantUsers) {
+ const wcaUserId = numericUserId(user);
+ if (!knownUserIds.has(wcaUserId)) {
+ continue;
+ }
+
+ await client.query(`
+ INSERT INTO app.room_participants (
+ room_id, user_id, competing, waiting_for, banned, in_room, registered,
+ source_updated_at
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ ON CONFLICT (room_id, user_id) DO UPDATE SET
+ competing = EXCLUDED.competing,
+ waiting_for = EXCLUDED.waiting_for,
+ banned = EXCLUDED.banned,
+ in_room = EXCLUDED.in_room,
+ registered = EXCLUDED.registered,
+ source_updated_at = EXCLUDED.source_updated_at,
+ ingested_at = now()
+ WHERE app.room_participants.source_updated_at < EXCLUDED.source_updated_at
+ OR (
+ app.room_participants.source_updated_at = EXCLUDED.source_updated_at
+ AND ROW(
+ app.room_participants.competing,
+ app.room_participants.waiting_for,
+ app.room_participants.banned,
+ app.room_participants.in_room,
+ app.room_participants.registered
+ ) IS DISTINCT FROM ROW(
+ EXCLUDED.competing,
+ EXCLUDED.waiting_for,
+ EXCLUDED.banned,
+ EXCLUDED.in_room,
+ EXCLUDED.registered
+ )
+ )
+ `, [
+ roomId,
+ stableId('user', wcaUserId),
+ mapValue(room.competing, wcaUserId),
+ mapValue(room.waitingFor, wcaUserId),
+ mapValue(room.banned, wcaUserId),
+ mapValue(room.inRoom, wcaUserId),
+ mapValue(room.registered, wcaUserId),
+ updatedAt,
+ ]);
+ }
+
+ return {
+ knownUserIds,
+ roomId,
+ updatedAt,
+ };
+};
+
+const upsertAttempt = async (client, room, roomState, attempt, attemptIndex) => {
+ const ordinal = Number.isInteger(attempt.id) ? attempt.id : attemptIndex;
+ const attemptMongoId = attempt._id
+ ? attempt._id.toString()
+ : `${room._id}:${room.event}:${attempt.createdAt || roomState.updatedAt}:${ordinal}`;
+ const attemptId = stableId('attempt', attemptMongoId);
+ const attemptUpdatedAt = sourceDate(attempt.updatedAt, roomState.updatedAt);
+
+ await client.query(`
+ INSERT INTO app.attempts (
+ id, mongo_id, room_id, ordinal, cube_event, scrambles,
+ source_created_at, source_updated_at
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ ON CONFLICT (mongo_id) DO UPDATE SET
+ ordinal = EXCLUDED.ordinal,
+ cube_event = EXCLUDED.cube_event,
+ scrambles = EXCLUDED.scrambles,
+ source_updated_at = EXCLUDED.source_updated_at,
+ ingested_at = now()
+ WHERE app.attempts.source_updated_at < EXCLUDED.source_updated_at
+ OR (
+ app.attempts.source_updated_at = EXCLUDED.source_updated_at
+ AND ROW(
+ app.attempts.ordinal,
+ app.attempts.cube_event,
+ app.attempts.scrambles
+ ) IS DISTINCT FROM ROW(
+ EXCLUDED.ordinal,
+ EXCLUDED.cube_event,
+ EXCLUDED.scrambles
+ )
+ )
+ `, [
+ attemptId,
+ attemptMongoId,
+ roomState.roomId,
+ ordinal,
+ room.event,
+ JSON.stringify(attempt.scrambles || []),
+ attempt.createdAt || null,
+ attemptUpdatedAt,
+ ]);
+
+ return {
+ attemptId,
+ attemptMongoId,
+ attemptUpdatedAt,
+ };
+};
+
+const upsertSolve = async (client, roomState, attemptState, wcaUserId, result) => {
+ if (!wcaUserId || !roomState.knownUserIds.has(wcaUserId) || !result) {
+ return;
+ }
+
+ const penalties = result.penalties || {};
+ const resultUpdatedAt = sourceDate(result.updatedAt, attemptState.attemptUpdatedAt);
+ const resultCreatedAt = sourceDate(result.createdAt, resultUpdatedAt);
+
+ await client.query(`
+ INSERT INTO app.solves (
+ id, attempt_id, room_id, user_id, time_ms, dnf,
+ inspection_penalty, auf_penalty, source_created_at, source_updated_at
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
+ ON CONFLICT (attempt_id, user_id) DO UPDATE SET
+ time_ms = EXCLUDED.time_ms,
+ dnf = EXCLUDED.dnf,
+ inspection_penalty = EXCLUDED.inspection_penalty,
+ auf_penalty = EXCLUDED.auf_penalty,
+ source_updated_at = EXCLUDED.source_updated_at,
+ ingested_at = now()
+ WHERE app.solves.source_updated_at < EXCLUDED.source_updated_at
+ OR (
+ app.solves.source_updated_at = EXCLUDED.source_updated_at
+ AND ROW(
+ app.solves.time_ms,
+ app.solves.dnf,
+ app.solves.inspection_penalty,
+ app.solves.auf_penalty
+ ) IS DISTINCT FROM ROW(
+ EXCLUDED.time_ms,
+ EXCLUDED.dnf,
+ EXCLUDED.inspection_penalty,
+ EXCLUDED.auf_penalty
+ )
+ )
+ `, [
+ stableId('solve', `${attemptState.attemptMongoId}:${wcaUserId}`),
+ attemptState.attemptId,
+ roomState.roomId,
+ stableId('user', wcaUserId),
+ result.time,
+ !!penalties.DNF,
+ !!penalties.inspection,
+ !!penalties.AUF,
+ resultCreatedAt,
+ resultUpdatedAt,
+ ]);
+};
+
+const syncAttemptResults = async (client, roomState, attemptState, attempt, userIds) => {
+ for (const userKey of userIds) {
+ const wcaUserId = numericUserId(userKey);
+ if (!wcaUserId || !roomState.knownUserIds.has(wcaUserId)) {
+ continue;
+ }
+
+ const result = resultValue(attempt.results, userKey);
+ if (result) {
+ await upsertSolve(client, roomState, attemptState, wcaUserId, result);
+ } else {
+ await client.query(`
+ DELETE FROM app.solves
+ WHERE attempt_id = $1 AND user_id = $2
+ `, [attemptState.attemptId, stableId('user', wcaUserId)]);
+ }
+ }
+};
+
+const writeRoomChanges = async (client, room, changes) => {
+ const resultUserIds = new Set();
+ if (changes.replaceAttempts) {
+ (room.attempts || []).forEach((attempt) => {
+ resultEntries(attempt.results).forEach(([userId]) => resultUserIds.add(userId));
+ });
+ } else {
+ (changes.attempts || []).forEach((change) => {
+ if (change.syncAllResults) {
+ const attempt = room.attempts && room.attempts[change.attemptIndex];
+ resultEntries(attempt && attempt.results)
+ .forEach(([userId]) => resultUserIds.add(userId));
+ } else {
+ (change.resultUserIds || []).forEach((userId) => resultUserIds.add(userId));
+ }
+ });
+ }
+
+ const roomState = await upsertRoomState(client, room, {
+ participantUserIds: changes.participantUserIds,
+ syncAllParticipants: changes.syncAllParticipants,
+ syncRoomOwners: changes.syncRoomOwners,
+ userIds: [...resultUserIds],
+ });
+ if (!roomState) {
+ return null;
+ }
+
+ if (changes.replaceAttempts) {
+ await client.query('DELETE FROM app.attempts WHERE room_id = $1', [roomState.roomId]);
+ for (const [attemptIndex, attempt] of (room.attempts || []).entries()) {
+ const attemptState = await upsertAttempt(client, room, roomState, attempt, attemptIndex);
+ await syncAttemptResults(
+ client,
+ roomState,
+ attemptState,
+ attempt,
+ resultEntries(attempt.results).map(([userId]) => userId),
+ );
+ }
+ return roomState.roomId;
+ }
+
+ for (const change of changes.attempts || []) {
+ const attempt = room.attempts && room.attempts[change.attemptIndex];
+ if (!attempt) {
+ continue;
+ }
+
+ const attemptState = await upsertAttempt(
+ client,
+ room,
+ roomState,
+ attempt,
+ change.attemptIndex,
+ );
+
+ if (change.syncAllResults) {
+ await client.query('DELETE FROM app.solves WHERE attempt_id = $1', [attemptState.attemptId]);
+ await syncAttemptResults(
+ client,
+ roomState,
+ attemptState,
+ attempt,
+ resultEntries(attempt.results).map(([userId]) => userId),
+ );
+ } else {
+ await syncAttemptResults(
+ client,
+ roomState,
+ attemptState,
+ attempt,
+ change.resultUserIds || [],
+ );
+ }
+ }
+
+ return roomState.roomId;
+};
+
+const mirrorRoomChanges = (room, changes = {}) => withTransaction(
+ (client) => writeRoomChanges(client, room, changes),
+);
+
+// Full snapshots are reserved for explicit backfills and do not delete newer live rows.
+const mirrorRoom = (room) => mirrorRoomChanges(room, {
+ attempts: (room.attempts || []).map((attempt, attemptIndex) => ({
+ attemptIndex,
+ resultUserIds: resultEntries(attempt.results).map(([userId]) => userId),
+ syncAllResults: false,
+ })),
+ syncAllParticipants: true,
+ syncRoomOwners: true,
+});
+
+const markRoomDeleted = (mongoId) => query(`
+ UPDATE app.rooms
+ SET deleted_at = now(), ingested_at = now()
+ WHERE mongo_id = $1
+`, [mongoId.toString()]);
+
+const mirrorMetricEvent = (event) => query(`
+ INSERT INTO analytics.events (
+ id, event_name, occurred_at, actor_id, room_id, properties, expires_at
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7)
+ ON CONFLICT (id) DO NOTHING
+`, [
+ event.eventId || uuidv4(),
+ event.event,
+ event.occurredAt,
+ event.actorId || null,
+ event.roomId || null,
+ {
+ actorType: event.actorType,
+ roomType: event.roomType,
+ cubeEvent: event.cubeEvent,
+ privateRoom: event.privateRoom,
+ failureReason: event.failureReason,
+ leaveReason: event.leaveReason,
+ activeUserCount: event.activeUserCount,
+ roomSolveCount: event.roomSolveCount,
+ durationMs: event.durationMs,
+ },
+ event.expiresAt,
+]);
+
+module.exports = {
+ markRoomDeleted,
+ mirrorMetricEvent,
+ mirrorRoom,
+ mirrorRoomChanges,
+ mirrorUser,
+ numericUserId,
+ stableId,
+};
diff --git a/server/postgres/dualWrite.test.js b/server/postgres/dualWrite.test.js
new file mode 100644
index 0000000..39503fe
--- /dev/null
+++ b/server/postgres/dualWrite.test.js
@@ -0,0 +1,190 @@
+/** @jest-environment node */
+/* eslint-env jest */
+
+jest.mock('./index', () => ({
+ query: jest.fn(),
+ withTransaction: jest.fn(),
+}));
+
+const postgres = require('./index');
+const {
+ markRoomDeleted,
+ mirrorMetricEvent,
+ mirrorRoom,
+ mirrorRoomChanges,
+ mirrorUser,
+ stableId,
+} = require('./dualWrite');
+
+const user = {
+ id: 1234,
+ email: 'solver@example.com',
+ name: 'Test Solver',
+ username: 'solver',
+ wcaId: '2026TEST01',
+ accessToken: 'must-not-be-mirrored',
+ showWCAID: true,
+ preferRealName: false,
+ useInspection: true,
+ timerType: 'spacebar',
+ muteTimer: false,
+ avatar: { thumb: 'avatar.png' },
+};
+
+describe('PostgreSQL dual writer', () => {
+ let client;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ client = { query: jest.fn().mockResolvedValue({ rows: [] }) };
+ postgres.withTransaction.mockImplementation((callback) => callback(client));
+ postgres.query.mockResolvedValue({ rows: [] });
+ });
+
+ it('uses deterministic, domain-separated identifiers', () => {
+ expect(stableId('user', 1234)).toBe(stableId('user', 1234));
+ expect(stableId('user', 1234)).not.toBe(stableId('room', 1234));
+ });
+
+ it('mirrors users without copying OAuth access tokens', async () => {
+ await mirrorUser(user);
+
+ expect(client.query).toHaveBeenCalledTimes(1);
+ const values = client.query.mock.calls[0][1];
+ expect(values).toContain(1234);
+ expect(values).toContain('solver@example.com');
+ expect(values).not.toContain('must-not-be-mirrored');
+ });
+
+ it('mirrors a room snapshot, participants, attempts, and solves', async () => {
+ const updatedAt = new Date('2026-07-09T20:00:00.000Z');
+ const room = {
+ _id: '507f1f77bcf86cd799439011',
+ name: 'Practice room',
+ event: '333',
+ accessCode: 'room-access-code',
+ password: 'bcrypt-hash',
+ type: 'normal',
+ owner: user,
+ admin: user,
+ users: [user],
+ competing: new Map([['1234', true]]),
+ waitingFor: new Map([['1234', false]]),
+ banned: new Map([['1234', false]]),
+ inRoom: new Map([['1234', true]]),
+ registered: new Map([['1234', true]]),
+ attempts: [{
+ _id: '507f1f77bcf86cd799439012',
+ id: 0,
+ scrambles: ['R U R\''],
+ results: new Map([['1234', {
+ time: -1,
+ penalties: { DNF: true, inspection: true, AUF: false },
+ createdAt: updatedAt,
+ updatedAt,
+ }]]),
+ createdAt: updatedAt,
+ updatedAt,
+ }],
+ requireRevealedIdentity: false,
+ started: false,
+ createdAt: updatedAt,
+ updatedAt,
+ };
+
+ await mirrorRoom(room);
+
+ const statements = client.query.mock.calls.map(([sql]) => sql);
+ expect(statements.some((sql) => sql.includes('INSERT INTO app.users'))).toBe(true);
+ expect(statements.some((sql) => sql.includes('INSERT INTO app.rooms'))).toBe(true);
+ expect(statements.some((sql) => sql.includes('INSERT INTO app.room_participants'))).toBe(true);
+ expect(statements.some((sql) => sql.includes('INSERT INTO app.attempts'))).toBe(true);
+ expect(statements.some((sql) => sql.includes('INSERT INTO app.solves'))).toBe(true);
+
+ const solveCall = client.query.mock.calls.find(([sql]) => sql.includes('INSERT INTO app.solves'));
+ expect(solveCall[0]).toContain('inspection_penalty');
+ expect(solveCall[1].slice(4, 8)).toEqual([-1, true, true, false]);
+ expect(solveCall[1]).not.toContain('room-access-code');
+ });
+
+ it('mirrors only explicitly changed attempts and results during live writes', async () => {
+ const updatedAt = new Date('2026-07-09T20:00:00.000Z');
+ const unrelatedUser = { id: 5678, name: 'Unchanged Solver' };
+ const room = {
+ _id: '507f1f77bcf86cd799439011',
+ name: 'Practice room',
+ event: '333',
+ accessCode: 'room-access-code',
+ type: 'normal',
+ owner: unrelatedUser,
+ admin: unrelatedUser,
+ users: [user, unrelatedUser],
+ competing: new Map([['1234', true], ['5678', true]]),
+ waitingFor: new Map([['1234', false], ['5678', true]]),
+ banned: new Map([['1234', false], ['5678', false]]),
+ inRoom: new Map([['1234', true], ['5678', true]]),
+ registered: new Map([['1234', true], ['5678', true]]),
+ attempts: [0, 1].map((id) => ({
+ _id: `507f1f77bcf86cd79943901${id + 2}`,
+ id,
+ scrambles: ['R U R\''],
+ results: new Map([['1234', {
+ time: 12000 + id,
+ penalties: {},
+ createdAt: updatedAt,
+ updatedAt,
+ }]]),
+ createdAt: updatedAt,
+ updatedAt,
+ })),
+ requireRevealedIdentity: false,
+ started: false,
+ createdAt: updatedAt,
+ updatedAt,
+ };
+
+ await mirrorRoomChanges(room, {
+ attempts: [{
+ attemptIndex: 1,
+ resultUserIds: ['1234'],
+ syncAllResults: false,
+ }],
+ participantUserIds: ['1234'],
+ });
+
+ const statements = client.query.mock.calls.map(([sql]) => sql);
+ expect(statements.filter((sql) => sql.includes('INSERT INTO app.users'))).toHaveLength(1);
+ expect(statements.filter((sql) => sql.includes('INSERT INTO app.room_participants')))
+ .toHaveLength(1);
+ expect(statements.filter((sql) => sql.includes('INSERT INTO app.attempts'))).toHaveLength(1);
+ expect(statements.filter((sql) => sql.includes('INSERT INTO app.solves'))).toHaveLength(1);
+ expect(statements.some((sql) => sql.includes('DELETE FROM app.attempts'))).toBe(false);
+ expect(client.query.mock.calls.find(([sql]) => sql.includes('INSERT INTO app.solves'))[1])
+ .toContain(12001);
+ });
+
+ it('mirrors sanitized metric events and soft-deletes rooms', async () => {
+ const occurredAt = new Date('2026-07-09T20:00:00.000Z');
+ await mirrorMetricEvent({
+ eventId: 'ad6bdef6-71f9-4264-b920-2b1e6ac061d5',
+ event: 'room_joined',
+ occurredAt,
+ expiresAt: new Date('2026-10-07T20:00:00.000Z'),
+ actorId: 'pseudonymous-user',
+ roomId: 'pseudonymous-room',
+ activeUserCount: 4,
+ });
+ await markRoomDeleted('507f1f77bcf86cd799439011');
+
+ expect(postgres.query).toHaveBeenNthCalledWith(
+ 1,
+ expect.stringContaining('INSERT INTO analytics.events'),
+ expect.arrayContaining(['room_joined', 'pseudonymous-user', 'pseudonymous-room']),
+ );
+ expect(postgres.query).toHaveBeenNthCalledWith(
+ 2,
+ expect.stringContaining('UPDATE app.rooms'),
+ ['507f1f77bcf86cd799439011'],
+ );
+ });
+});
diff --git a/server/postgres/index.js b/server/postgres/index.js
new file mode 100644
index 0000000..47242f4
--- /dev/null
+++ b/server/postgres/index.js
@@ -0,0 +1,125 @@
+const { Pool } = require('pg');
+
+const config = require('../runtimeConfig');
+const logger = require('../logger');
+
+const RETRY_DELAY_MS = 30 * 1000;
+
+const poolConfig = config.postgres.connectionString
+ ? { connectionString: config.postgres.connectionString }
+ : {
+ host: config.postgres.host,
+ port: config.postgres.port,
+ database: config.postgres.database,
+ user: config.postgres.user,
+ password: config.postgres.password,
+ };
+
+if (config.postgres.ssl) {
+ poolConfig.ssl = {
+ rejectUnauthorized: config.postgres.sslRejectUnauthorized,
+ ...(config.postgres.sslCa ? { ca: config.postgres.sslCa } : {}),
+ };
+}
+
+const pool = new Pool({
+ ...poolConfig,
+ connectionTimeoutMillis: 3000,
+ idleTimeoutMillis: 30000,
+ max: 10,
+});
+
+pool.on('error', (err) => {
+ logger.error(err);
+});
+
+let initialized = false;
+let initializationPromise;
+let lastFailureAt = 0;
+
+const initializePostgres = async () => {
+ if (!config.postgres.enabled) {
+ return false;
+ }
+ if (initialized) {
+ return true;
+ }
+ if (initializationPromise) {
+ return initializationPromise;
+ }
+ if (Date.now() - lastFailureAt < RETRY_DELAY_MS) {
+ return false;
+ }
+
+ initializationPromise = (async () => {
+ await pool.query('SELECT 1');
+ initialized = true;
+ logger.info('[POSTGRES] Connected');
+ return true;
+ })().catch((err) => {
+ lastFailureAt = Date.now();
+ logger.error(err);
+ return false;
+ }).finally(() => {
+ initializationPromise = undefined;
+ });
+
+ return initializationPromise;
+};
+
+const withTransaction = async (callback) => {
+ if (!(await initializePostgres())) {
+ return null;
+ }
+
+ let client;
+ try {
+ client = await pool.connect();
+ await client.query('BEGIN');
+ const result = await callback(client);
+ await client.query('COMMIT');
+ return result;
+ } catch (err) {
+ if (client) {
+ await client.query('ROLLBACK').catch(() => {});
+ }
+ logger.error(err);
+ return null;
+ } finally {
+ if (client) {
+ client.release();
+ }
+ }
+};
+
+const query = async (...args) => {
+ if (!(await initializePostgres())) {
+ return null;
+ }
+
+ try {
+ return await pool.query(...args);
+ } catch (err) {
+ logger.error(err);
+ return null;
+ }
+};
+
+const deleteExpiredMetrics = () => query(
+ 'DELETE FROM analytics.events WHERE expires_at <= now()',
+);
+
+const startPostgresMaintenance = () => {
+ deleteExpiredMetrics();
+ const timer = setInterval(deleteExpiredMetrics, 6 * 60 * 60 * 1000);
+ timer.unref();
+};
+
+module.exports = {
+ deleteExpiredMetrics,
+ initializePostgres,
+ pool,
+ query,
+ startPostgresMaintenance,
+ withTransaction,
+};
diff --git a/server/prisma.config.mjs b/server/prisma.config.mjs
new file mode 100644
index 0000000..eba7576
--- /dev/null
+++ b/server/prisma.config.mjs
@@ -0,0 +1,28 @@
+import { defineConfig } from 'prisma/config';
+
+const databaseUrl = () => {
+ if (process.env.DIRECT_DATABASE_URL || process.env.DATABASE_URL) {
+ return process.env.DIRECT_DATABASE_URL || process.env.DATABASE_URL;
+ }
+
+ const user = encodeURIComponent(process.env.PGUSER || 'letscube');
+ const password = encodeURIComponent(process.env.PGPASSWORD
+ || process.env.POSTGRES_PASSWORD
+ || 'letscube');
+ const host = process.env.PGHOST || '127.0.0.1';
+ const port = process.env.PGPORT || '5432';
+ const database = encodeURIComponent(process.env.PGDATABASE || 'letscube');
+ const ssl = process.env.PGSSL === 'true' ? '?sslmode=require' : '';
+
+ return `postgresql://${user}:${password}@${host}:${port}/${database}${ssl}`;
+};
+
+export default defineConfig({
+ schema: 'prisma/schema.prisma',
+ migrations: {
+ path: 'prisma/migrations',
+ },
+ datasource: {
+ url: databaseUrl(),
+ },
+});
diff --git a/server/prisma/migrations/20260709230000_initial_schema/migration.sql b/server/prisma/migrations/20260709230000_initial_schema/migration.sql
new file mode 100644
index 0000000..fd0f015
--- /dev/null
+++ b/server/prisma/migrations/20260709230000_initial_schema/migration.sql
@@ -0,0 +1,107 @@
+CREATE SCHEMA IF NOT EXISTS analytics;
+CREATE SCHEMA IF NOT EXISTS app;
+
+CREATE TABLE app.users (
+ id uuid PRIMARY KEY,
+ wca_user_id bigint NOT NULL UNIQUE,
+ email text,
+ name text NOT NULL,
+ username text,
+ wca_id text,
+ preferences jsonb NOT NULL DEFAULT '{}'::jsonb,
+ avatar jsonb NOT NULL DEFAULT '{}'::jsonb,
+ source_created_at timestamptz,
+ source_updated_at timestamptz NOT NULL,
+ ingested_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX users_username_lower_idx
+ ON app.users (lower(username))
+ WHERE username IS NOT NULL AND username <> '';
+
+CREATE TABLE app.rooms (
+ id uuid PRIMARY KEY,
+ mongo_id text NOT NULL UNIQUE,
+ name text NOT NULL,
+ cube_event text NOT NULL,
+ access_code text NOT NULL UNIQUE,
+ password_hash text,
+ room_type text NOT NULL CHECK (room_type IN ('normal', 'grand_prix')),
+ owner_id uuid REFERENCES app.users(id) ON DELETE SET NULL,
+ admin_id uuid REFERENCES app.users(id) ON DELETE SET NULL,
+ require_revealed_identity boolean NOT NULL DEFAULT false,
+ start_time timestamptz,
+ started boolean NOT NULL DEFAULT false,
+ next_solve_at timestamptz,
+ expires_at timestamptz,
+ twitch_channel text,
+ deleted_at timestamptz,
+ source_created_at timestamptz,
+ source_updated_at timestamptz NOT NULL,
+ ingested_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE app.room_participants (
+ room_id uuid NOT NULL REFERENCES app.rooms(id) ON DELETE CASCADE,
+ user_id uuid NOT NULL REFERENCES app.users(id) ON DELETE CASCADE,
+ competing boolean NOT NULL DEFAULT false,
+ waiting_for boolean NOT NULL DEFAULT false,
+ banned boolean NOT NULL DEFAULT false,
+ in_room boolean NOT NULL DEFAULT false,
+ registered boolean NOT NULL DEFAULT false,
+ source_updated_at timestamptz NOT NULL,
+ ingested_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (room_id, user_id)
+);
+
+CREATE TABLE app.attempts (
+ id uuid PRIMARY KEY,
+ mongo_id text NOT NULL CONSTRAINT attempts_mongo_id_unique UNIQUE,
+ room_id uuid NOT NULL REFERENCES app.rooms(id) ON DELETE CASCADE,
+ ordinal integer NOT NULL CHECK (ordinal >= 0),
+ cube_event text NOT NULL,
+ scrambles jsonb NOT NULL,
+ source_created_at timestamptz,
+ source_updated_at timestamptz NOT NULL,
+ ingested_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX attempts_room_ordinal_idx
+ ON app.attempts (room_id, ordinal);
+
+CREATE TABLE app.solves (
+ id uuid PRIMARY KEY,
+ attempt_id uuid NOT NULL REFERENCES app.attempts(id) ON DELETE CASCADE,
+ room_id uuid NOT NULL REFERENCES app.rooms(id) ON DELETE CASCADE,
+ user_id uuid NOT NULL REFERENCES app.users(id) ON DELETE CASCADE,
+ time_ms integer NOT NULL CHECK (time_ms >= -1),
+ penalties jsonb NOT NULL DEFAULT '{}'::jsonb,
+ source_created_at timestamptz,
+ source_updated_at timestamptz NOT NULL,
+ ingested_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (attempt_id, user_id)
+);
+
+CREATE INDEX solves_user_created_idx
+ ON app.solves (user_id, source_created_at DESC);
+
+CREATE TABLE analytics.events (
+ id uuid PRIMARY KEY,
+ event_name text NOT NULL,
+ occurred_at timestamptz NOT NULL,
+ actor_id text,
+ room_id text,
+ properties jsonb NOT NULL DEFAULT '{}'::jsonb,
+ expires_at timestamptz NOT NULL,
+ ingested_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX analytics_events_name_time_idx
+ ON analytics.events (event_name, occurred_at DESC);
+
+CREATE INDEX analytics_events_room_time_idx
+ ON analytics.events (room_id, occurred_at DESC)
+ WHERE room_id IS NOT NULL;
+
+CREATE INDEX analytics_events_expiry_idx
+ ON analytics.events (expires_at);
diff --git a/server/prisma/migrations/20260709234500_normalize_solve_penalties/migration.sql b/server/prisma/migrations/20260709234500_normalize_solve_penalties/migration.sql
new file mode 100644
index 0000000..70ea34a
--- /dev/null
+++ b/server/prisma/migrations/20260709234500_normalize_solve_penalties/migration.sql
@@ -0,0 +1,20 @@
+ALTER TABLE app.solves
+ ADD COLUMN dnf boolean NOT NULL DEFAULT false,
+ ADD COLUMN inspection_penalty boolean NOT NULL DEFAULT false,
+ ADD COLUMN auf_penalty boolean NOT NULL DEFAULT false;
+
+UPDATE app.solves
+SET
+ dnf = COALESCE((penalties ->> 'DNF')::boolean, false),
+ inspection_penalty = COALESCE((penalties ->> 'inspection')::boolean, false),
+ auf_penalty = COALESCE((penalties ->> 'AUF')::boolean, false),
+ source_created_at = COALESCE(source_created_at, source_updated_at);
+
+ALTER TABLE app.solves
+ ALTER COLUMN source_created_at SET NOT NULL,
+ DROP COLUMN penalties;
+
+DROP INDEX app.solves_user_created_idx;
+
+CREATE INDEX solves_user_created_idx
+ ON app.solves (user_id, source_created_at DESC, id DESC);
diff --git a/server/prisma/migrations/migration_lock.toml b/server/prisma/migrations/migration_lock.toml
new file mode 100644
index 0000000..2fe25d8
--- /dev/null
+++ b/server/prisma/migrations/migration_lock.toml
@@ -0,0 +1 @@
+provider = "postgresql"
diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma
new file mode 100644
index 0000000..25c9a74
--- /dev/null
+++ b/server/prisma/schema.prisma
@@ -0,0 +1,130 @@
+datasource db {
+ provider = "postgresql"
+ schemas = ["analytics", "app"]
+}
+
+model User {
+ id String @id @db.Uuid
+ wcaUserId BigInt @unique(map: "users_wca_user_id_key") @map("wca_user_id")
+ email String?
+ name String
+ username String?
+ wcaId String? @map("wca_id")
+ preferences Json @default("{}") @db.JsonB
+ avatar Json @default("{}") @db.JsonB
+ sourceCreatedAt DateTime? @map("source_created_at") @db.Timestamptz(6)
+ sourceUpdatedAt DateTime @map("source_updated_at") @db.Timestamptz(6)
+ ingestedAt DateTime @default(now()) @map("ingested_at") @db.Timestamptz(6)
+ ownedRooms Room[] @relation("RoomOwner")
+ administeredRooms Room[] @relation("RoomAdmin")
+ roomParticipations RoomParticipant[]
+ solves Solve[]
+
+ @@map("users")
+ @@schema("app")
+}
+
+model Room {
+ id String @id @db.Uuid
+ mongoId String @unique(map: "rooms_mongo_id_key") @map("mongo_id")
+ name String
+ cubeEvent String @map("cube_event")
+ accessCode String @unique(map: "rooms_access_code_key") @map("access_code")
+ passwordHash String? @map("password_hash")
+ roomType String @map("room_type")
+ ownerId String? @map("owner_id") @db.Uuid
+ adminId String? @map("admin_id") @db.Uuid
+ requireRevealedIdentity Boolean @default(false) @map("require_revealed_identity")
+ startTime DateTime? @map("start_time") @db.Timestamptz(6)
+ started Boolean @default(false)
+ nextSolveAt DateTime? @map("next_solve_at") @db.Timestamptz(6)
+ expiresAt DateTime? @map("expires_at") @db.Timestamptz(6)
+ twitchChannel String? @map("twitch_channel")
+ deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
+ sourceCreatedAt DateTime? @map("source_created_at") @db.Timestamptz(6)
+ sourceUpdatedAt DateTime @map("source_updated_at") @db.Timestamptz(6)
+ ingestedAt DateTime @default(now()) @map("ingested_at") @db.Timestamptz(6)
+ owner User? @relation("RoomOwner", fields: [ownerId], references: [id], onDelete: SetNull, onUpdate: NoAction)
+ admin User? @relation("RoomAdmin", fields: [adminId], references: [id], onDelete: SetNull, onUpdate: NoAction)
+ participants RoomParticipant[]
+ attempts Attempt[]
+ solves Solve[]
+
+ @@map("rooms")
+ @@schema("app")
+}
+
+model RoomParticipant {
+ roomId String @map("room_id") @db.Uuid
+ userId String @map("user_id") @db.Uuid
+ competing Boolean @default(false)
+ waitingFor Boolean @default(false) @map("waiting_for")
+ banned Boolean @default(false)
+ inRoom Boolean @default(false) @map("in_room")
+ registered Boolean @default(false)
+ sourceUpdatedAt DateTime @map("source_updated_at") @db.Timestamptz(6)
+ ingestedAt DateTime @default(now()) @map("ingested_at") @db.Timestamptz(6)
+ room Room @relation(fields: [roomId], references: [id], onDelete: Cascade, onUpdate: NoAction)
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction)
+
+ @@id([roomId, userId])
+ @@map("room_participants")
+ @@schema("app")
+}
+
+model Attempt {
+ id String @id @db.Uuid
+ mongoId String @unique(map: "attempts_mongo_id_unique") @map("mongo_id")
+ roomId String @map("room_id") @db.Uuid
+ ordinal Int
+ cubeEvent String @map("cube_event")
+ scrambles Json @db.JsonB
+ sourceCreatedAt DateTime? @map("source_created_at") @db.Timestamptz(6)
+ sourceUpdatedAt DateTime @map("source_updated_at") @db.Timestamptz(6)
+ ingestedAt DateTime @default(now()) @map("ingested_at") @db.Timestamptz(6)
+ room Room @relation(fields: [roomId], references: [id], onDelete: Cascade, onUpdate: NoAction)
+ solves Solve[]
+
+ @@index([roomId, ordinal], map: "attempts_room_ordinal_idx")
+ @@map("attempts")
+ @@schema("app")
+}
+
+model Solve {
+ id String @id @db.Uuid
+ attemptId String @map("attempt_id") @db.Uuid
+ roomId String @map("room_id") @db.Uuid
+ userId String @map("user_id") @db.Uuid
+ timeMs Int @map("time_ms")
+ dnf Boolean @default(false)
+ inspectionPenalty Boolean @default(false) @map("inspection_penalty")
+ aufPenalty Boolean @default(false) @map("auf_penalty")
+ sourceCreatedAt DateTime @map("source_created_at") @db.Timestamptz(6)
+ sourceUpdatedAt DateTime @map("source_updated_at") @db.Timestamptz(6)
+ ingestedAt DateTime @default(now()) @map("ingested_at") @db.Timestamptz(6)
+ attempt Attempt @relation(fields: [attemptId], references: [id], onDelete: Cascade, onUpdate: NoAction)
+ room Room @relation(fields: [roomId], references: [id], onDelete: Cascade, onUpdate: NoAction)
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction)
+
+ @@unique([attemptId, userId], map: "solves_attempt_id_user_id_key")
+ @@index([userId, sourceCreatedAt(sort: Desc), id(sort: Desc)], map: "solves_user_created_idx")
+ @@map("solves")
+ @@schema("app")
+}
+
+model MetricEvent {
+ id String @id @db.Uuid
+ eventName String @map("event_name")
+ occurredAt DateTime @map("occurred_at") @db.Timestamptz(6)
+ actorId String? @map("actor_id")
+ roomId String? @map("room_id")
+ properties Json @default("{}") @db.JsonB
+ expiresAt DateTime @map("expires_at") @db.Timestamptz(6)
+ ingestedAt DateTime @default(now()) @map("ingested_at") @db.Timestamptz(6)
+
+ @@index([eventName, occurredAt(sort: Desc)], map: "analytics_events_name_time_idx")
+ @@index([roomId, occurredAt(sort: Desc)], map: "analytics_events_room_time_idx")
+ @@index([expiresAt], map: "analytics_events_expiry_idx")
+ @@map("events")
+ @@schema("analytics")
+}
diff --git a/server/runtimeConfig.js b/server/runtimeConfig.js
index 1762c33..16ed0f2 100644
--- a/server/runtimeConfig.js
+++ b/server/runtimeConfig.js
@@ -13,6 +13,20 @@ const parseOrigins = (value, fallback) => {
return value.split(',').map((origin) => origin.trim()).filter(Boolean);
};
+const parsePositiveInteger = (value, fallback) => {
+ const parsed = Number.parseInt(value, 10);
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
+};
+
+const parseNonNegativeInteger = (value, fallback) => {
+ const parsed = Number.parseInt(value, 10);
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
+};
+
+const authSecret = process.env.AUTH_SECRET
+ || process.env.SESSION_SECRET
+ || baseConfig.auth.secret;
+
const redisUrl = process.env.REDIS_URL
|| (baseConfig.redis && baseConfig.redis.url)
|| 'redis://localhost:6379';
@@ -49,13 +63,17 @@ module.exports = {
socketio: {
...baseConfig.socketio,
port: parsePort(process.env.SOCKETIO_PORT, baseConfig.socketio.port),
+ reconnectGraceMs: parseNonNegativeInteger(
+ process.env.ROOM_RECONNECT_GRACE_MS,
+ baseConfig.socketio.reconnectGraceMs,
+ ),
},
mongodb: process.env.MONGO_URL || process.env.MONGODB_URI || baseConfig.mongodb,
redis,
wcaSource: process.env.WCA_SOURCE || process.env.REACT_APP_WCA_ORIGIN || baseConfig.wcaSource,
auth: {
...baseConfig.auth,
- secret: process.env.AUTH_SECRET || process.env.SESSION_SECRET || baseConfig.auth.secret,
+ secret: authSecret,
callbackURL: process.env.AUTH_CALLBACK_URL
|| baseConfig.auth.callbackURL
|| baseConfig.auth.callbackUrl,
@@ -71,4 +89,29 @@ module.exports = {
...baseConfig.cors,
origin: parseOrigins(process.env.CORS_ORIGINS, baseConfig.cors.origin),
},
+ metrics: {
+ enabled: process.env.METRICS_ENABLED !== 'false',
+ hashSecret: process.env.METRICS_HASH_SECRET || authSecret,
+ retentionDays: parsePositiveInteger(process.env.METRICS_RETENTION_DAYS, 90),
+ },
+ grandPrix: {
+ enabled: process.env.GRAND_PRIX_ENABLED === 'true',
+ },
+ postgres: {
+ enabled: process.env.POSTGRES_ENABLED !== 'false',
+ connectionString: process.env.DATABASE_URL,
+ host: process.env.PGHOST || (baseConfig.postgres && baseConfig.postgres.host) || '127.0.0.1',
+ port: parsePort(process.env.PGPORT, (baseConfig.postgres && baseConfig.postgres.port) || 5432),
+ database: process.env.PGDATABASE
+ || (baseConfig.postgres && baseConfig.postgres.database)
+ || 'letscube',
+ user: process.env.PGUSER || (baseConfig.postgres && baseConfig.postgres.user) || 'letscube',
+ password: process.env.PGPASSWORD
+ || process.env.POSTGRES_PASSWORD
+ || (baseConfig.postgres && baseConfig.postgres.password)
+ || 'letscube',
+ ssl: process.env.PGSSL === 'true',
+ sslRejectUnauthorized: process.env.PGSSL_REJECT_UNAUTHORIZED !== 'false',
+ sslCa: process.env.PGSSL_CA && process.env.PGSSL_CA.replace(/\\n/g, '\n'),
+ },
};
diff --git a/server/runtimeConfig.test.js b/server/runtimeConfig.test.js
new file mode 100644
index 0000000..c18e344
--- /dev/null
+++ b/server/runtimeConfig.test.js
@@ -0,0 +1,32 @@
+/* eslint-env jest */
+
+// eslint-disable-next-line global-require
+const loadRuntimeConfig = () => require('./runtimeConfig');
+
+describe('Grand Prix runtime configuration', () => {
+ const originalValue = process.env.GRAND_PRIX_ENABLED;
+
+ afterEach(() => {
+ if (originalValue === undefined) {
+ delete process.env.GRAND_PRIX_ENABLED;
+ } else {
+ process.env.GRAND_PRIX_ENABLED = originalValue;
+ }
+ jest.resetModules();
+ });
+
+ it('is disabled by default', () => {
+ delete process.env.GRAND_PRIX_ENABLED;
+
+ expect(loadRuntimeConfig().grandPrix.enabled).toBe(false);
+ });
+
+ it('is enabled only by the explicit lowercase true value', () => {
+ process.env.GRAND_PRIX_ENABLED = 'true';
+ expect(loadRuntimeConfig().grandPrix.enabled).toBe(true);
+
+ jest.resetModules();
+ process.env.GRAND_PRIX_ENABLED = 'TRUE';
+ expect(loadRuntimeConfig().grandPrix.enabled).toBe(false);
+ });
+});
diff --git a/server/socket/index.js b/server/socket/index.js
index 18a5d10..dc4ebff 100644
--- a/server/socket/index.js
+++ b/server/socket/index.js
@@ -7,6 +7,8 @@ const expressSocketSession = require('express-socket.io-session');
const config = require('../runtimeConfig');
const session = require('../middlewares/session');
const { connect } = require('../database');
+const { createHealthHandler, createHealthReporter } = require('../health');
+const { initializePostgres, pool } = require('../postgres');
const logger = require('../logger');
const loggerMiddleware = require('./middlewares/logger');
const authenticateMiddleware = require('./middlewares/authenticate');
@@ -49,6 +51,7 @@ const init = async () => {
io.on('error', logSocketError('server'));
const mongoose = await connect();
+ await initializePostgres();
const pubClient = new Redis(config.redis.url || {
host: config.redis.host,
@@ -63,6 +66,31 @@ const init = async () => {
io.adapter(createAdapter(pubClient, subClient));
+ const reportHealth = createHealthReporter({
+ service: 'socket',
+ checks: {
+ mongodb: () => mongoose.connection.readyState === 1,
+ postgres: {
+ required: false,
+ check: async () => {
+ if (config.postgres.enabled) {
+ await pool.query('SELECT 1');
+ }
+ return true;
+ },
+ },
+ redis: async () => pubClient.status === 'ready'
+ && subClient.status === 'ready'
+ && (await pubClient.ping()) === 'PONG',
+ },
+ });
+ const handleHealth = createHealthHandler(reportHealth);
+ server.on('request', (req, res) => {
+ if (req.method === 'GET' && req.url.split('?')[0] === '/health/socket') {
+ handleHealth(req, res);
+ }
+ });
+
const middlewares = [
expressSocketSession(session(mongoose), {
autoSave: true,
@@ -72,7 +100,7 @@ const init = async () => {
];
initRooms(io, middlewares);
- initDefault(io, middlewares);
+ initDefault(io, middlewares, reportHealth);
watchNamespaceErrors(io, '/');
watchNamespaceErrors(io, '/rooms');
diff --git a/server/socket/lib/commands.js b/server/socket/lib/commands.js
index 08bdc5e..dbd5e6d 100644
--- a/server/socket/lib/commands.js
+++ b/server/socket/lib/commands.js
@@ -1,4 +1,4 @@
-const Protocol = require('../../../client/src/lib/protocol');
+const Protocol = require('../../../client/src/lib/protocol.json');
const ChatMessage = require('./ChatMessage');
const Commands = {
diff --git a/server/socket/lib/reconnectGrace.js b/server/socket/lib/reconnectGrace.js
new file mode 100644
index 0000000..162b2ed
--- /dev/null
+++ b/server/socket/lib/reconnectGrace.js
@@ -0,0 +1,98 @@
+const timerKey = (roomId, userId) => `${roomId}:${userId}`;
+
+const createReconnectGrace = ({
+ graceMs,
+ hasActiveSockets,
+ finalizeDeparture,
+ listActiveUsers,
+ logger,
+ setTimer = setTimeout,
+ clearTimer = clearTimeout,
+}) => {
+ const timers = new Map();
+ const finalizationQueues = new Map();
+
+ const cancel = (roomId, userId) => {
+ const key = timerKey(roomId, userId);
+ const timer = timers.get(key);
+ if (timer) {
+ clearTimer(timer);
+ timers.delete(key);
+ }
+
+ const finalization = finalizationQueues.get(roomId.toString());
+ return finalization
+ ? finalization.then(() => true, () => true)
+ : Promise.resolve(false);
+ };
+
+ const enqueueFinalization = (departure, requireInactive) => {
+ const roomKey = departure.roomId.toString();
+ const previous = finalizationQueues.get(roomKey) || Promise.resolve();
+ const current = previous.catch(() => {}).then(async () => {
+ if (requireInactive
+ && await hasActiveSockets(departure.userId, departure.roomId)) {
+ return false;
+ }
+
+ return finalizeDeparture(departure);
+ });
+
+ finalizationQueues.set(roomKey, current);
+ current.finally(() => {
+ if (finalizationQueues.get(roomKey) === current) {
+ finalizationQueues.delete(roomKey);
+ }
+ }).catch(() => {});
+
+ return current;
+ };
+
+ const finalizeIfInactive = (departure) => enqueueFinalization(departure, true);
+ const finalize = (departure) => enqueueFinalization(departure, false);
+
+ const schedule = (departure) => {
+ cancel(departure.roomId, departure.userId);
+
+ const key = timerKey(departure.roomId, departure.userId);
+ const timer = setTimer(() => {
+ timers.delete(key);
+ finalizeIfInactive(departure).catch((err) => logger.error(err));
+ }, graceMs);
+
+ if (timer && timer.unref) {
+ timer.unref();
+ }
+
+ timers.set(key, timer);
+ };
+
+ const reconcile = async () => {
+ const activeUsers = await listActiveUsers();
+ await Promise.all(activeUsers.map((departure) => finalizeIfInactive(departure)));
+ };
+
+ const startReconciliation = () => {
+ const timer = setTimer(() => {
+ reconcile().catch((err) => logger.error(err));
+ }, graceMs);
+
+ if (timer && timer.unref) {
+ timer.unref();
+ }
+
+ return timer;
+ };
+
+ return {
+ cancel,
+ finalize,
+ reconcile,
+ schedule,
+ startReconciliation,
+ };
+};
+
+module.exports = {
+ createReconnectGrace,
+};
diff --git a/server/socket/lib/reconnectGrace.test.js b/server/socket/lib/reconnectGrace.test.js
new file mode 100644
index 0000000..0d5cf8b
--- /dev/null
+++ b/server/socket/lib/reconnectGrace.test.js
@@ -0,0 +1,187 @@
+/* eslint-env jest */
+
+const { createReconnectGrace } = require('./reconnectGrace');
+
+describe('reconnect grace', () => {
+ const departure = {
+ roomId: 'room-123',
+ userId: 101,
+ connectionId: 'socket-123',
+ leaveReason: 'disconnect',
+ };
+ let hasActiveSockets;
+ let finalizeDeparture;
+ let listActiveUsers;
+ let logger;
+
+ const createManager = (graceMs = 30000) => createReconnectGrace({
+ graceMs,
+ hasActiveSockets,
+ finalizeDeparture,
+ listActiveUsers,
+ logger,
+ });
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ hasActiveSockets = jest.fn().mockResolvedValue(false);
+ finalizeDeparture = jest.fn().mockResolvedValue(true);
+ listActiveUsers = jest.fn().mockResolvedValue([]);
+ logger = { error: jest.fn() };
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('keeps the user in the room until the reconnect grace expires', async () => {
+ let markFinalized;
+ const finalized = new Promise((resolve) => {
+ markFinalized = resolve;
+ });
+ finalizeDeparture.mockImplementationOnce(async (value) => {
+ markFinalized(value);
+ return true;
+ });
+ const manager = createManager();
+
+ manager.schedule(departure);
+ jest.advanceTimersByTime(29999);
+ await Promise.resolve();
+
+ expect(finalizeDeparture).not.toHaveBeenCalled();
+
+ jest.advanceTimersByTime(1);
+ await finalized;
+
+ expect(hasActiveSockets).toHaveBeenCalledWith(101, 'room-123');
+ expect(finalizeDeparture).toHaveBeenCalledWith(departure);
+ });
+
+ it('cancels pending cleanup when the user reconnects', async () => {
+ const manager = createManager();
+
+ manager.schedule(departure);
+ manager.cancel(departure.roomId, departure.userId);
+ jest.runAllTimers();
+ await Promise.resolve();
+
+ expect(hasActiveSockets).not.toHaveBeenCalled();
+ expect(finalizeDeparture).not.toHaveBeenCalled();
+ });
+
+ it('lets a reconnect wait for cleanup already in progress', async () => {
+ let finishFinalization;
+ let markFinalizationStarted;
+ const finalizationStarted = new Promise((resolve) => {
+ markFinalizationStarted = resolve;
+ });
+ finalizeDeparture.mockImplementationOnce(() => new Promise((resolve) => {
+ markFinalizationStarted();
+ finishFinalization = resolve;
+ }));
+ const manager = createManager();
+
+ manager.schedule(departure);
+ jest.runAllTimers();
+ await finalizationStarted;
+
+ let reconnectReleased = false;
+ const reconnect = manager.cancel(departure.roomId, departure.userId)
+ .then((waited) => {
+ reconnectReleased = true;
+ return waited;
+ });
+ await Promise.resolve();
+
+ expect(reconnectReleased).toBe(false);
+
+ finishFinalization(true);
+ await expect(reconnect).resolves.toBe(true);
+ });
+
+ it('finalizes an explicit departure without waiting for socket inactivity', async () => {
+ const manager = createManager();
+
+ await manager.finalize({ ...departure, leaveReason: 'explicit' });
+
+ expect(hasActiveSockets).not.toHaveBeenCalled();
+ expect(finalizeDeparture).toHaveBeenCalledWith({
+ ...departure,
+ leaveReason: 'explicit',
+ });
+ });
+
+ it('does not remove a user who has another active socket', async () => {
+ let markChecked;
+ const checked = new Promise((resolve) => {
+ markChecked = resolve;
+ });
+ hasActiveSockets.mockImplementationOnce(async () => {
+ markChecked();
+ return true;
+ });
+ const manager = createManager();
+
+ manager.schedule(departure);
+ jest.runAllTimers();
+ await checked;
+
+ expect(finalizeDeparture).not.toHaveBeenCalled();
+ });
+
+ it('reconciles users left active by a replaced socket process', async () => {
+ const connected = { ...departure, userId: 202 };
+ listActiveUsers.mockResolvedValue([departure, connected]);
+ hasActiveSockets.mockImplementation(async (userId) => userId === 202);
+ const manager = createManager();
+
+ await manager.reconcile();
+
+ expect(finalizeDeparture).toHaveBeenCalledTimes(1);
+ expect(finalizeDeparture).toHaveBeenCalledWith(departure);
+ });
+
+ it('serializes cleanup for users in the same room', async () => {
+ let finishFirstDeparture;
+ let markFirstStarted;
+ const firstStarted = new Promise((resolve) => {
+ markFirstStarted = resolve;
+ });
+ const secondDeparture = { ...departure, userId: 202 };
+ listActiveUsers.mockResolvedValue([departure, secondDeparture]);
+ finalizeDeparture
+ .mockImplementationOnce(() => new Promise((resolve) => {
+ markFirstStarted();
+ finishFirstDeparture = resolve;
+ }))
+ .mockResolvedValueOnce(true);
+ const manager = createManager();
+
+ const reconciliation = manager.reconcile();
+ await firstStarted;
+
+ expect(finalizeDeparture).toHaveBeenCalledTimes(1);
+
+ finishFirstDeparture(true);
+ await reconciliation;
+
+ expect(finalizeDeparture).toHaveBeenCalledTimes(2);
+ expect(finalizeDeparture).toHaveBeenLastCalledWith(secondDeparture);
+ });
+
+ it('delays startup reconciliation by the same grace window', async () => {
+ listActiveUsers.mockResolvedValue([departure]);
+ const manager = createManager(45000);
+
+ manager.startReconciliation();
+ jest.advanceTimersByTime(44999);
+ expect(listActiveUsers).not.toHaveBeenCalled();
+
+ jest.advanceTimersByTime(1);
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(listActiveUsers).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/server/socket/lib/resultSubmission.js b/server/socket/lib/resultSubmission.js
new file mode 100644
index 0000000..4a1c4d9
--- /dev/null
+++ b/server/socket/lib/resultSubmission.js
@@ -0,0 +1,219 @@
+const MAX_ATTEMPT_KEY_LENGTH = 128;
+const MAX_SUBMISSION_ID_LENGTH = 128;
+
+class ResultSubmissionError extends Error {
+ constructor(statusCode, message, retryable, cause) {
+ super(message);
+ this.name = 'ResultSubmissionError';
+ this.statusCode = statusCode;
+ this.retryable = retryable;
+ this.cause = cause;
+ }
+
+ toResponse() {
+ return {
+ statusCode: this.statusCode,
+ message: this.message,
+ retryable: this.retryable,
+ };
+ }
+}
+
+const submissionError = (statusCode, message, retryable = false, cause) => (
+ new ResultSubmissionError(statusCode, message, retryable, cause)
+);
+
+const normalizedPenalties = (penalties = {}) => ({
+ AUF: !!penalties.AUF,
+ DNF: !!penalties.DNF,
+ inspection: !!penalties.inspection,
+});
+
+const resultsMatch = (existingResult, incomingResult) => (
+ existingResult.time === incomingResult.time
+ && Object.entries(normalizedPenalties(existingResult.penalties)).every(([key, value]) => (
+ normalizedPenalties(incomingResult.penalties)[key] === value
+ ))
+);
+
+const validateSubmission = ({
+ attemptId, attemptKey, result, submissionId,
+}) => {
+ if (!Number.isInteger(attemptId) || attemptId < 0) {
+ throw submissionError(400, 'Invalid ID for attempt submission');
+ }
+
+ if (!result || !Number.isFinite(result.time)) {
+ throw submissionError(400, 'Invalid result submission');
+ }
+
+ if (result.penalties !== undefined
+ && result.penalties !== null
+ && (typeof result.penalties !== 'object' || Array.isArray(result.penalties))) {
+ throw submissionError(400, 'Invalid result penalties');
+ }
+
+ if (submissionId !== undefined
+ && (typeof submissionId !== 'string'
+ || submissionId.trim().length === 0
+ || submissionId.length > MAX_SUBMISSION_ID_LENGTH)) {
+ throw submissionError(400, 'Invalid result submission ID');
+ }
+
+ if (attemptKey !== undefined
+ && (typeof attemptKey !== 'string'
+ || attemptKey.trim().length === 0
+ || attemptKey.length > MAX_ATTEMPT_KEY_LENGTH)) {
+ throw submissionError(400, 'Invalid result attempt key');
+ }
+
+ return {
+ attemptId,
+ attemptKey: attemptKey === undefined ? undefined : attemptKey.trim(),
+ result: {
+ time: result.time,
+ penalties: { ...(result.penalties || {}) },
+ },
+ submissionId: submissionId === undefined ? undefined : submissionId.trim(),
+ };
+};
+
+const createResultSubmissionService = ({
+ advanceRoom = async (room) => room,
+ fetchRoom,
+}) => {
+ const queues = new Map();
+
+ const enqueue = (key, task) => {
+ const previous = queues.get(key) || Promise.resolve();
+ const current = previous.catch(() => {}).then(task);
+ queues.set(key, current);
+ current.finally(() => {
+ if (queues.get(key) === current) {
+ queues.delete(key);
+ }
+ }).catch(() => {});
+ return current;
+ };
+
+ const submit = ({
+ roomId,
+ userId,
+ attemptId,
+ attemptKey,
+ result,
+ submissionId,
+ onSaved = () => {},
+ }) => {
+ const submission = validateSubmission({
+ attemptId, attemptKey, result, submissionId,
+ });
+ const userKey = userId.toString();
+ const queueKey = roomId.toString();
+
+ return enqueue(queueKey, async () => {
+ try {
+ const room = await fetchRoom(roomId);
+ if (!room) {
+ throw submissionError(404, 'Could not find room for result submission');
+ }
+
+ if (!room.inRoom.get(userKey) || room.banned.get(userKey)) {
+ throw submissionError(403, 'Not authorized to submit a result to this room');
+ }
+
+ const attempt = room.attempts[submission.attemptId];
+ if (!attempt) {
+ throw submissionError(400, 'Invalid ID for attempt submission');
+ }
+ if (submission.attemptKey !== undefined
+ && (!attempt._id || attempt._id.toString() !== submission.attemptKey)) {
+ throw submissionError(409, 'Attempt changed before result submission');
+ }
+
+ if (room.type === 'grand_prix') {
+ submission.result.penalties.DNF = submission.result.penalties.DNF
+ || submission.attemptId < room.attempts.length - 1;
+ }
+
+ const previousResult = attempt.results.get(userKey);
+ let outcome;
+ if (previousResult) {
+ if (submission.submissionId
+ && previousResult.submissionId === submission.submissionId) {
+ outcome = {
+ room,
+ result: previousResult,
+ status: 'duplicate',
+ submissionId: submission.submissionId,
+ };
+ } else if (submission.submissionId
+ && !previousResult.submissionId
+ && resultsMatch(previousResult, submission.result)) {
+ previousResult.submissionId = submission.submissionId;
+ if (typeof room.markModified === 'function') {
+ room.markModified(`attempts.${submission.attemptId}.results.${userKey}`);
+ }
+ const savedRoom = await room.save();
+ outcome = {
+ room: savedRoom,
+ result: savedRoom.attempts[submission.attemptId].results.get(userKey),
+ status: 'duplicate',
+ submissionId: submission.submissionId,
+ };
+ } else {
+ throw submissionError(409, 'A result already exists for this attempt');
+ }
+ } else {
+ const storedResult = {
+ ...submission.result,
+ ...(submission.submissionId ? { submissionId: submission.submissionId } : {}),
+ };
+ attempt.results.set(userKey, storedResult);
+ if (submission.attemptId === room.attempts.length - 1) {
+ room.waitingFor.set(userKey, false);
+ }
+
+ const savedRoom = await room.save();
+ outcome = {
+ room: savedRoom,
+ result: savedRoom.attempts[submission.attemptId].results.get(userKey),
+ status: 'saved',
+ submissionId: submission.submissionId,
+ };
+ await onSaved(outcome);
+ }
+
+ if (typeof outcome.room.doneWithScramble === 'function'
+ && outcome.room.doneWithScramble()) {
+ try {
+ outcome.room = await advanceRoom(outcome.room);
+ } catch (err) {
+ throw submissionError(
+ 503,
+ 'Result saved, but failed to advance the room',
+ true,
+ err,
+ );
+ }
+ }
+
+ return outcome;
+ } catch (err) {
+ if (err instanceof ResultSubmissionError) {
+ throw err;
+ }
+ throw submissionError(503, 'Failed to save result', true, err);
+ }
+ });
+ };
+
+ return { submit };
+};
+
+module.exports = {
+ createResultSubmissionService,
+ ResultSubmissionError,
+ resultsMatch,
+ validateSubmission,
+};
diff --git a/server/socket/lib/resultSubmission.test.js b/server/socket/lib/resultSubmission.test.js
new file mode 100644
index 0000000..6ac809b
--- /dev/null
+++ b/server/socket/lib/resultSubmission.test.js
@@ -0,0 +1,397 @@
+/* eslint-env jest */
+
+const {
+ createResultSubmissionService,
+ ResultSubmissionError,
+ validateSubmission,
+} = require('./resultSubmission');
+
+const makeRoom = ({
+ attempts = 1,
+ existingResult,
+ inRoom = true,
+ banned = false,
+ type = 'normal',
+} = {}) => {
+ const room = {
+ _id: 'room-123',
+ type,
+ inRoom: new Map([['42', inRoom], ['43', inRoom]]),
+ banned: new Map([['42', banned], ['43', banned]]),
+ waitingFor: new Map([['42', true], ['43', true]]),
+ attempts: Array.from({ length: attempts }, (_, id) => ({
+ _id: `attempt-${id}`,
+ id,
+ results: new Map(),
+ })),
+ };
+ if (existingResult) {
+ room.attempts[0].results.set('42', existingResult);
+ }
+ room.save = jest.fn().mockImplementation(async () => room);
+ return room;
+};
+
+const submit = (service, overrides = {}) => service.submit({
+ roomId: 'room-123',
+ userId: 42,
+ attemptId: 0,
+ result: {
+ time: 12345,
+ penalties: { AUF: false },
+ },
+ submissionId: 'submission-123',
+ ...overrides,
+});
+
+describe('result submission validation', () => {
+ it('accepts legacy submissions without an id', () => {
+ expect(validateSubmission({
+ attemptId: 0,
+ result: { time: 1234, penalties: {} },
+ })).toEqual({
+ attemptId: 0,
+ attemptKey: undefined,
+ result: { time: 1234, penalties: {} },
+ submissionId: undefined,
+ });
+ });
+
+ it.each([
+ [{ attemptId: -1, result: { time: 1234 } }, 'Invalid ID for attempt submission'],
+ [{ attemptId: 0, result: { time: Number.NaN } }, 'Invalid result submission'],
+ [{ attemptId: 0, result: { time: 1234 }, submissionId: '' }, 'Invalid result submission ID'],
+ [{ attemptId: 0, attemptKey: 123, result: { time: 1234 } }, 'Invalid result attempt key'],
+ ])('rejects invalid submission payloads', (payload, message) => {
+ expect(() => validateSubmission(payload)).toThrow(message);
+ });
+});
+
+describe('createResultSubmissionService', () => {
+ it('persists a result before reporting it as saved', async () => {
+ const room = makeRoom();
+ const service = createResultSubmissionService({
+ fetchRoom: jest.fn().mockResolvedValue(room),
+ });
+
+ await expect(submit(service)).resolves.toMatchObject({
+ room,
+ status: 'saved',
+ submissionId: 'submission-123',
+ });
+ expect(room.save).toHaveBeenCalledTimes(1);
+ expect(room.waitingFor.get('42')).toBe(false);
+ expect(room.attempts[0].results.get('42')).toMatchObject({
+ time: 12345,
+ penalties: { AUF: false },
+ submissionId: 'submission-123',
+ });
+ });
+
+ it('accepts a matching immutable attempt key', async () => {
+ const room = makeRoom();
+ const service = createResultSubmissionService({
+ fetchRoom: jest.fn().mockResolvedValue(room),
+ });
+
+ await expect(submit(service, { attemptKey: 'attempt-0' })).resolves.toMatchObject({
+ status: 'saved',
+ });
+ });
+
+ it('rejects a reused attempt number when the immutable attempt key changed', async () => {
+ const room = makeRoom();
+ const service = createResultSubmissionService({
+ fetchRoom: jest.fn().mockResolvedValue(room),
+ });
+
+ await expect(submit(service, { attemptKey: 'attempt-before-event-reset' }))
+ .rejects.toMatchObject({
+ statusCode: 409,
+ retryable: false,
+ });
+ expect(room.save).not.toHaveBeenCalled();
+ });
+
+ it('treats the same submission id as a duplicate without saving again', async () => {
+ const existingResult = {
+ time: 12345,
+ penalties: {},
+ submissionId: 'submission-123',
+ };
+ const room = makeRoom({ existingResult });
+ const service = createResultSubmissionService({
+ fetchRoom: jest.fn().mockResolvedValue(room),
+ });
+
+ await expect(submit(service)).resolves.toEqual({
+ room,
+ result: existingResult,
+ status: 'duplicate',
+ submissionId: 'submission-123',
+ });
+ expect(room.save).not.toHaveBeenCalled();
+ });
+
+ it('adopts an id for an identical result saved by an older server', async () => {
+ const existingResult = {
+ time: 12345,
+ penalties: { DNF: false, inspection: false },
+ };
+ const room = makeRoom({ existingResult });
+ room.markModified = jest.fn();
+ const service = createResultSubmissionService({
+ fetchRoom: jest.fn().mockResolvedValue(room),
+ });
+
+ await expect(submit(service, {
+ result: {
+ time: 12345,
+ penalties: { AUF: false },
+ },
+ })).resolves.toMatchObject({
+ status: 'duplicate',
+ submissionId: 'submission-123',
+ });
+ expect(existingResult.submissionId).toBe('submission-123');
+ expect(room.markModified).toHaveBeenCalledWith('attempts.0.results.42');
+ expect(room.save).toHaveBeenCalledTimes(1);
+ });
+
+ it('rejects a mismatched result saved by an older server', async () => {
+ const existingResult = {
+ time: 11111,
+ penalties: {},
+ };
+ const room = makeRoom({ existingResult });
+ const service = createResultSubmissionService({
+ fetchRoom: jest.fn().mockResolvedValue(room),
+ });
+
+ await expect(submit(service)).rejects.toMatchObject({
+ statusCode: 409,
+ retryable: false,
+ });
+ expect(existingResult.submissionId).toBeUndefined();
+ expect(room.save).not.toHaveBeenCalled();
+ });
+
+ it('rejects a conflicting result without overwriting it', async () => {
+ const existingResult = {
+ time: 11111,
+ penalties: {},
+ submissionId: 'older-submission',
+ };
+ const room = makeRoom({ existingResult });
+ const service = createResultSubmissionService({
+ fetchRoom: jest.fn().mockResolvedValue(room),
+ });
+
+ await expect(submit(service)).rejects.toMatchObject({
+ statusCode: 409,
+ retryable: false,
+ });
+ expect(room.attempts[0].results.get('42')).toBe(existingResult);
+ expect(room.save).not.toHaveBeenCalled();
+ });
+
+ it('allows submission to an original attempt after the room advances', async () => {
+ const room = makeRoom({ attempts: 2 });
+ const service = createResultSubmissionService({
+ fetchRoom: jest.fn().mockResolvedValue(room),
+ });
+
+ await expect(submit(service, { attemptId: 0 })).resolves.toMatchObject({
+ status: 'saved',
+ });
+ expect(room.attempts[0].results.has('42')).toBe(true);
+ expect(room.attempts[1].results.has('42')).toBe(false);
+ expect(room.waitingFor.get('42')).toBe(true);
+ });
+
+ it('serializes duplicate submissions in one room', async () => {
+ const room = makeRoom();
+ let finishSave;
+ let notifySaveStarted;
+ const saveStarted = new Promise((resolve) => {
+ notifySaveStarted = resolve;
+ });
+ room.save.mockImplementation(() => new Promise((resolve) => {
+ finishSave = () => resolve(room);
+ notifySaveStarted();
+ }));
+ const fetchRoom = jest.fn().mockResolvedValue(room);
+ const service = createResultSubmissionService({ fetchRoom });
+
+ const first = submit(service);
+ await saveStarted;
+ const second = submit(service);
+ await Promise.resolve();
+
+ expect(fetchRoom).toHaveBeenCalledTimes(1);
+ finishSave();
+
+ await expect(first).resolves.toMatchObject({ status: 'saved' });
+ await expect(second).resolves.toMatchObject({ status: 'duplicate' });
+ expect(fetchRoom).toHaveBeenCalledTimes(2);
+ expect(room.save).toHaveBeenCalledTimes(1);
+ });
+
+ it('serializes simultaneous submissions from different users in one room', async () => {
+ const room = makeRoom();
+ let finishFirstSave;
+ let notifyFirstSaveStarted;
+ const firstSaveStarted = new Promise((resolve) => {
+ notifyFirstSaveStarted = resolve;
+ });
+ room.save
+ .mockImplementationOnce(() => new Promise((resolve) => {
+ finishFirstSave = () => resolve(room);
+ notifyFirstSaveStarted();
+ }))
+ .mockImplementation(async () => room);
+ const fetchRoom = jest.fn().mockResolvedValue(room);
+ const service = createResultSubmissionService({ fetchRoom });
+
+ const first = submit(service);
+ await firstSaveStarted;
+ const second = submit(service, {
+ userId: 43,
+ submissionId: 'submission-456',
+ result: { time: 23456, penalties: {} },
+ });
+ await Promise.resolve();
+
+ expect(fetchRoom).toHaveBeenCalledTimes(1);
+ finishFirstSave();
+
+ await expect(first).resolves.toMatchObject({ status: 'saved' });
+ await expect(second).resolves.toMatchObject({ status: 'saved' });
+ expect(fetchRoom).toHaveBeenCalledTimes(2);
+ expect(room.save).toHaveBeenCalledTimes(2);
+ expect(room.attempts[0].results.has('42')).toBe(true);
+ expect(room.attempts[0].results.has('43')).toBe(true);
+ expect(room.waitingFor.get('42')).toBe(false);
+ expect(room.waitingFor.get('43')).toBe(false);
+ });
+
+ it('repairs failed room advancement on a duplicate retry without republishing', async () => {
+ const room = makeRoom();
+ room.doneWithScramble = jest.fn(() => (
+ room.attempts.length === 1 && room.waitingFor.get('42') === false
+ ));
+ const advanceError = new Error('new attempt save failed');
+ const advanceRoom = jest.fn()
+ .mockRejectedValueOnce(advanceError)
+ .mockImplementation(async (currentRoom) => {
+ currentRoom.attempts.push({
+ _id: 'attempt-1',
+ id: 1,
+ results: new Map(),
+ });
+ currentRoom.waitingFor.set('42', true);
+ return currentRoom;
+ });
+ const onSaved = jest.fn();
+ const service = createResultSubmissionService({
+ advanceRoom,
+ fetchRoom: jest.fn().mockResolvedValue(room),
+ });
+
+ await expect(submit(service, { onSaved })).rejects.toMatchObject({
+ statusCode: 503,
+ retryable: true,
+ cause: advanceError,
+ });
+ expect(room.attempts[0].results.get('42')).toMatchObject({
+ submissionId: 'submission-123',
+ });
+ expect(onSaved).toHaveBeenCalledTimes(1);
+
+ await expect(submit(service, { onSaved })).resolves.toMatchObject({
+ status: 'duplicate',
+ });
+ expect(advanceRoom).toHaveBeenCalledTimes(2);
+ expect(room.attempts).toHaveLength(2);
+ expect(onSaved).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not advance twice when duplicate retries arrive concurrently', async () => {
+ const existingResult = {
+ time: 12345,
+ penalties: {},
+ submissionId: 'submission-123',
+ };
+ const room = makeRoom({ existingResult });
+ room.waitingFor.set('42', false);
+ room.doneWithScramble = jest.fn(() => room.attempts.length === 1);
+ let finishAdvance;
+ let notifyAdvanceStarted;
+ const advanceStarted = new Promise((resolve) => {
+ notifyAdvanceStarted = resolve;
+ });
+ const advanceRoom = jest.fn(() => new Promise((resolve) => {
+ notifyAdvanceStarted();
+ finishAdvance = () => {
+ room.attempts.push({
+ _id: 'attempt-1',
+ id: 1,
+ results: new Map(),
+ });
+ resolve(room);
+ };
+ }));
+ const fetchRoom = jest.fn().mockResolvedValue(room);
+ const service = createResultSubmissionService({ advanceRoom, fetchRoom });
+
+ const first = submit(service);
+ await advanceStarted;
+ const second = submit(service);
+ await Promise.resolve();
+
+ expect(fetchRoom).toHaveBeenCalledTimes(1);
+ finishAdvance();
+
+ await expect(first).resolves.toMatchObject({ status: 'duplicate' });
+ await expect(second).resolves.toMatchObject({ status: 'duplicate' });
+ expect(fetchRoom).toHaveBeenCalledTimes(2);
+ expect(advanceRoom).toHaveBeenCalledTimes(1);
+ expect(room.attempts).toHaveLength(2);
+ });
+
+ it.each([
+ [{ inRoom: false }, 403, false],
+ [{ banned: true }, 403, false],
+ [{ attempts: 0 }, 400, false],
+ ])('rejects invalid room state', async (roomOptions, statusCode, retryable) => {
+ const room = makeRoom(roomOptions);
+ const service = createResultSubmissionService({
+ fetchRoom: jest.fn().mockResolvedValue(room),
+ });
+
+ await expect(submit(service)).rejects.toMatchObject({ statusCode, retryable });
+ expect(room.save).not.toHaveBeenCalled();
+ });
+
+ it('marks database failures as retryable without exposing their details', async () => {
+ const databaseError = new Error('private connection details');
+ const service = createResultSubmissionService({
+ fetchRoom: jest.fn().mockRejectedValue(databaseError),
+ });
+
+ let failure;
+ try {
+ await submit(service);
+ } catch (err) {
+ failure = err;
+ }
+
+ expect(failure).toBeInstanceOf(ResultSubmissionError);
+ expect(failure.toResponse()).toEqual({
+ statusCode: 503,
+ message: 'Failed to save result',
+ retryable: true,
+ });
+ expect(failure.cause).toBe(databaseError);
+ });
+});
diff --git a/server/socket/lib/roomAvailability.js b/server/socket/lib/roomAvailability.js
new file mode 100644
index 0000000..7b09f67
--- /dev/null
+++ b/server/socket/lib/roomAvailability.js
@@ -0,0 +1,5 @@
+const isRoomTypeEnabled = (type, grandPrixEnabled) => (
+ type !== 'grand_prix' || grandPrixEnabled
+);
+
+module.exports = { isRoomTypeEnabled };
diff --git a/server/socket/lib/roomAvailability.test.js b/server/socket/lib/roomAvailability.test.js
new file mode 100644
index 0000000..e9cacc9
--- /dev/null
+++ b/server/socket/lib/roomAvailability.test.js
@@ -0,0 +1,15 @@
+/* eslint-env jest */
+
+const { isRoomTypeEnabled } = require('./roomAvailability');
+
+describe('room type availability', () => {
+ it('always exposes normal rooms', () => {
+ expect(isRoomTypeEnabled('normal', false)).toBe(true);
+ expect(isRoomTypeEnabled('normal', true)).toBe(true);
+ });
+
+ it('exposes Grand Prix rooms only when explicitly enabled', () => {
+ expect(isRoomTypeEnabled('grand_prix', false)).toBe(false);
+ expect(isRoomTypeEnabled('grand_prix', true)).toBe(true);
+ });
+});
diff --git a/server/socket/namespaces/default.js b/server/socket/namespaces/default.js
index 5266f81..0c2084a 100644
--- a/server/socket/namespaces/default.js
+++ b/server/socket/namespaces/default.js
@@ -1,7 +1,7 @@
const logger = require('../../logger');
-const Protocol = require('../../../client/src/lib/protocol');
+const Protocol = require('../../../client/src/lib/protocol.json');
-module.exports = (io, middlewares) => {
+module.exports = (io, middlewares, reportHealth) => {
const ns = io.of('/');
middlewares.forEach((middleware) => {
@@ -24,6 +24,15 @@ module.exports = (io, middlewares) => {
updateUsersOnline();
+ socket.on(Protocol.HEALTH_CHECK, async (acknowledgment) => {
+ const report = await reportHealth();
+ if (typeof acknowledgment === 'function') {
+ acknowledgment(report);
+ } else {
+ socket.emit(Protocol.HEALTH_STATUS, report);
+ }
+ });
+
socket.on(Protocol.DISCONNECT, () => {
logger.info(`socket ${socket.id} disconnected from default;`);
updateUsersOnline();
diff --git a/server/socket/namespaces/default.test.js b/server/socket/namespaces/default.test.js
new file mode 100644
index 0000000..cc71e21
--- /dev/null
+++ b/server/socket/namespaces/default.test.js
@@ -0,0 +1,60 @@
+/* eslint-env jest */
+
+const Protocol = require('../../../client/src/lib/protocol.json');
+const initDefault = require('./default');
+
+jest.mock('../../logger', () => ({
+ debug: jest.fn(),
+ error: jest.fn(),
+ info: jest.fn(),
+}));
+
+describe('default socket namespace health check', () => {
+ const setup = (report) => {
+ const handlers = {};
+ const socket = {
+ id: 'socket-123',
+ user: null,
+ on: jest.fn((event, handler) => {
+ handlers[event] = handler;
+ }),
+ emit: jest.fn(),
+ };
+ const namespace = {
+ adapter: {
+ allRooms: jest.fn().mockResolvedValue(new Set()),
+ },
+ emit: jest.fn(),
+ on: jest.fn((event, handler) => {
+ handlers[event] = handler;
+ }),
+ use: jest.fn(),
+ };
+ const io = { of: jest.fn().mockReturnValue(namespace) };
+ const reportHealth = jest.fn().mockResolvedValue(report);
+
+ initDefault(io, [], reportHealth);
+ handlers.connection(socket);
+
+ return { handlers, reportHealth, socket };
+ };
+
+ it('acknowledges a health check with the current report', async () => {
+ const report = { status: 'ok', service: 'socket' };
+ const { handlers, reportHealth } = setup(report);
+ const acknowledgment = jest.fn();
+ await handlers[Protocol.HEALTH_CHECK](acknowledgment);
+
+ expect(reportHealth).toHaveBeenCalledTimes(1);
+ expect(acknowledgment).toHaveBeenCalledWith(report);
+ });
+
+ it('emits health status when no acknowledgment is provided', async () => {
+ const report = { status: 'ok', service: 'socket' };
+ const { handlers, socket } = setup(report);
+
+ await handlers[Protocol.HEALTH_CHECK]();
+
+ expect(socket.emit).toHaveBeenCalledWith(Protocol.HEALTH_STATUS, report);
+ });
+});
diff --git a/server/socket/namespaces/rooms.js b/server/socket/namespaces/rooms.js
index 877f013..e61f614 100644
--- a/server/socket/namespaces/rooms.js
+++ b/server/socket/namespaces/rooms.js
@@ -1,14 +1,23 @@
const _ = require('lodash');
const bcrypt = require('bcrypt');
-const Protocol = require('../../../client/src/lib/protocol');
+const Protocol = require('../../../client/src/lib/protocol.json');
const ChatMessage = require('../lib/ChatMessage');
const { parseCommand } = require('../lib/commands');
+const config = require('../../runtimeConfig');
const logger = require('../../logger');
+const metrics = require('../../metrics');
+const { markRoomDeleted } = require('../../postgres/dualWrite');
const { Room, User } = require('../../models');
const { encodeUserRoom } = require('../utils');
const roomMap = require('../lib/roomMap');
const { canAccessRoom, canDeleteRoom } = require('../lib/roomAuthorization');
+const { isRoomTypeEnabled: checkRoomTypeEnabled } = require('../lib/roomAvailability');
const { removeUserFromRoomSockets } = require('../lib/roomSockets');
+const { createReconnectGrace } = require('../lib/reconnectGrace');
+const {
+ createResultSubmissionService,
+ ResultSubmissionError,
+} = require('../lib/resultSubmission');
const {
createSafeSocketHandler,
optionalAcknowledgment,
@@ -33,12 +42,14 @@ const roomMask = (room) => ({
// Data for people in room
const joinRoomMask = _.partial(_.pick, _, privateRoomKeys);
+const isRoomTypeEnabled = (type) => checkRoomTypeEnabled(type, config.grandPrix.enabled);
const fetchRoom = (id) => Room.findById({ _id: id }).populate('users').populate('admin').populate('owner');
const getRooms = (userId) => Room.find().populate('users').populate('admin').populate('owner')
.then((rooms) => rooms.filter((room) => (
- userId ? !room.banned.get(userId.toString()) : true
+ isRoomTypeEnabled(room.type)
+ && (userId ? !room.banned.get(userId.toString()) : true)
)).map(roomMask));
const roomTimerObj = {};
@@ -59,45 +70,98 @@ module.exports = (io, middlewares) => {
return [...sockets].some((socketId) => socketId !== currentSocketId);
};
- async function markNormalRoomsStaleOnStartup() {
- const rooms = await Room.find({ type: 'normal' })
+ const hasActiveSocketsForUserRoom = async (userId, roomId) => (
+ (await socketsForUserRoom(userId, roomId)).size > 0
+ );
+
+ async function listActiveRoomUsers() {
+ const rooms = await Room.find()
.populate('users')
.populate('admin')
.populate('owner');
- await Promise.all(rooms.map(async (room) => {
- room.users.forEach((user) => {
- room.inRoom.set(user.id.toString(), false);
- room.waitingFor.set(user.id.toString(), false);
- });
+ return rooms.flatMap((room) => room.usersInRoom.map((user) => ({
+ roomId: room._id,
+ userId: user.id,
+ leaveReason: 'disconnect',
+ })));
+ }
- room.admin = null;
- await room.updateStale(true);
- }));
+ async function createAndSendNewScramble(room) {
+ const updatedRoom = await room.newAttempt();
+ logger.debug('Sending new scramble to room', { roomId: room.id });
+ ns().in(room.accessCode).emit(Protocol.NEW_ATTEMPT, {
+ waitingFor: updatedRoom.waitingFor,
+ attempt: updatedRoom.attempts[updatedRoom.attempts.length - 1],
+ });
+ return updatedRoom;
+ }
- logger.info('Marked normal rooms stale on socket startup', {
- rooms: rooms.length,
+ function sendNewScramble(room) {
+ return createAndSendNewScramble(room).catch((err) => {
+ logger.error(err);
});
}
- const normalRoomsReady = markNormalRoomsStaleOnStartup().catch((err) => {
- logger.error(err);
+ const resultSubmissions = createResultSubmissionService({
+ advanceRoom: createAndSendNewScramble,
+ fetchRoom,
});
- function sendNewScramble(room) {
- return room.newAttempt().then((r) => {
- logger.debug('Sending new scramble to room', { roomId: room.id });
- ns().in(room.accessCode).emit(Protocol.NEW_ATTEMPT, {
- waitingFor: r.waitingFor,
- attempt: r.attempts[r.attempts.length - 1],
- });
+ function sendGlobalRoomUpdate(room) {
+ if (isRoomTypeEnabled(room.type)) {
+ ns().emit(Protocol.GLOBAL_ROOM_UPDATED, roomMask(room));
+ }
+ }
- return r;
- }).catch((err) => {
- logger.error(err);
+ async function finalizeUserDeparture({
+ roomId, userId, connectionId, leaveReason,
+ }) {
+ const room = await fetchRoom(roomId);
+ const userKey = userId.toString();
+
+ if (!room || !room.inRoom.get(userKey)) {
+ return false;
+ }
+
+ if (leaveReason === 'disconnect'
+ && await hasActiveSocketsForUserRoom(userId, roomId)) {
+ return false;
+ }
+
+ const updatedRoom = await room.dropUser({ id: userId }, (_room) => {
+ ns().in(room.accessCode).emit(Protocol.UPDATE_ADMIN, _room.admin);
});
+
+ await metrics.endRoomVisit({
+ room: updatedRoom,
+ userId,
+ connectionId,
+ leaveReason,
+ activeUserCount: updatedRoom.usersLength,
+ });
+
+ ns().in(updatedRoom.accessCode).emit(Protocol.USER_LEFT, userId);
+ sendGlobalRoomUpdate(updatedRoom);
+
+ if (updatedRoom.doneWithScramble()) {
+ logger.debug('everyone done, sending new scramble');
+ await sendNewScramble(updatedRoom);
+ }
+
+ return true;
}
+ const reconnectGrace = createReconnectGrace({
+ graceMs: config.socketio.reconnectGraceMs,
+ hasActiveSockets: hasActiveSocketsForUserRoom,
+ finalizeDeparture: finalizeUserDeparture,
+ listActiveUsers: listActiveRoomUsers,
+ logger,
+ });
+
+ reconnectGrace.startReconciliation();
+
const interval = 60 * 1000; // 30 seconds
function startTimer(room) {
@@ -153,16 +217,18 @@ module.exports = (io, middlewares) => {
clearInterval(roomTimerObj[room._id]);
}
- Room.find({ type: 'grand_prix' })
- .then((rooms) => {
- rooms.forEach(async (room) => {
- if (room.startTime && Date.now() < new Date(room.startTime).getTime()) {
- awaitRoomStart(room);
- } else {
- startTimer(room);
- }
+ if (config.grandPrix.enabled) {
+ Room.find({ type: 'grand_prix' })
+ .then((rooms) => {
+ rooms.forEach(async (room) => {
+ if (room.startTime && Date.now() < new Date(room.startTime).getTime()) {
+ awaitRoomStart(room);
+ } else {
+ startTimer(room);
+ }
+ });
});
- });
+ }
async function updateClientsWithUsers() {
const sockets = [...await ns().adapter.allRooms([])]
@@ -181,8 +247,6 @@ module.exports = (io, middlewares) => {
}
ns().on('connection', async (socket) => {
- await normalRoomsReady;
-
const on = createSafeSocketHandler(socket, logger, Protocol.ERROR);
logger.debug(`socket ${socket.id} connected to rooms; logged in as ${socket.user ? socket.user.name : 'Anonymous'}`);
@@ -271,7 +335,7 @@ module.exports = (io, middlewares) => {
}
// Only deals with removing authenticated users from a room
- async function leaveRoom() {
+ async function leaveRoom(leaveReason) {
try {
const hasOtherSockets = await hasOtherSocketsForUserRoom(
socket.userId,
@@ -282,17 +346,13 @@ module.exports = (io, middlewares) => {
return;
}
- const room = await socket.room.dropUser(socket.user, (_room) => {
- ns().in(socket.room.accessCode).emit(Protocol.UPDATE_ADMIN, _room.admin);
+ reconnectGrace.cancel(socket.roomId, socket.userId);
+ await reconnectGrace.finalize({
+ roomId: socket.roomId,
+ userId: socket.userId,
+ connectionId: socket.id,
+ leaveReason,
});
-
- broadcast(Protocol.USER_LEFT, socket.user.id);
- ns().emit(Protocol.GLOBAL_ROOM_UPDATED, roomMask(room));
-
- if (room.doneWithScramble()) {
- logger.debug('everyone done, sending new scramble');
- sendNewScramble(room);
- }
} catch (e) {
logger.error(e);
}
@@ -301,6 +361,12 @@ module.exports = (io, middlewares) => {
async function joinRoom(room, cb, spectating) {
if (socket.roomId) {
logger.debug('Socket is already in room', { roomId: socket.room._id });
+ await metrics.recordRoomJoinFailure({
+ room: socket.room,
+ userId: socket.userId,
+ connectionId: socket.id,
+ failureReason: 'already_in_room',
+ });
return cb({
statusCode: 400,
message: 'Socket is already in room',
@@ -312,30 +378,58 @@ module.exports = (io, middlewares) => {
if (!socket.user) {
logger.debug('Socket is not authenticated but joining anyways', { roomId: room._id, userId: socket.userId });
+ socket.room = room;
+ await metrics.beginRoomVisit({
+ room,
+ connectionId: socket.id,
+ activeUserCount: room.usersLength,
+ });
return cb(null, joinRoomMask(room));
}
socket.join(encodeUserRoom(socket.userId, room._id));
+ const waitedForCleanup = await reconnectGrace.cancel(room._id, socket.userId);
+ const activeRoom = waitedForCleanup ? await fetchRoom(room._id) : room;
+ if (!activeRoom) {
+ return cb({
+ statusCode: 404,
+ message: 'Room no longer exists',
+ });
+ }
- const r = await room.addUser(socket.user, spectating, (_room) => {
+ const r = await activeRoom.addUser(socket.user, spectating, (_room) => {
ns().in(_room.accessCode).emit(Protocol.UPDATE_ADMIN, _room.admin);
});
if (!r) {
- // Join the socket to the room anyways but don't add them
- return cb(null, joinRoomMask(room));
+ // The user is already present in persisted room state.
+ socket.room = activeRoom;
+ await metrics.beginRoomVisit({
+ room: activeRoom,
+ userId: socket.userId,
+ connectionId: socket.id,
+ activeUserCount: activeRoom.usersLength,
+ });
+ return cb(null, joinRoomMask(activeRoom));
}
socket.room = r;
+ await metrics.beginRoomVisit({
+ room: r,
+ userId: socket.userId,
+ connectionId: socket.id,
+ activeUserCount: r.usersLength,
+ replaceActive: true,
+ });
socket.emit(Protocol.JOIN, joinRoomMask(r));
cb(null, joinRoomMask(r));
- broadcast(Protocol.USER_JOIN, socket.user); // tell everyone
- ns().emit(Protocol.GLOBAL_ROOM_UPDATED, roomMask(r));
+ broadcast(Protocol.USER_JOIN, socket.user);
+ sendGlobalRoomUpdate(r);
- if (room.doneWithScramble()) {
+ if (r.doneWithScramble()) {
logger.debug('everyone done, sending new scramble');
- sendNewScramble(room);
+ sendNewScramble(r);
}
}
@@ -344,49 +438,66 @@ module.exports = (io, middlewares) => {
const acknowledgment = optionalAcknowledgment(cb);
const { id, spectating, password } = payload || {};
+ const rejectJoin = async (failureReason, error, room) => {
+ await metrics.recordRoomJoinFailure({
+ room,
+ userId: socket.userId,
+ connectionId: socket.id,
+ failureReason,
+ });
+ return acknowledgment(error, room ? roomMask(room) : undefined);
+ };
+
try {
const room = await fetchRoom(id);
if (!room) {
- return acknowledgment({
+ return rejectJoin('not_found', {
statusCode: 404,
message: `Could not find room with id ${id}`,
});
}
+ if (!isRoomTypeEnabled(room.type)) {
+ return rejectJoin('grand_prix_disabled', {
+ statusCode: 403,
+ message: 'Grand Prix rooms are disabled',
+ }, room);
+ }
+
if (room.private && !password) {
- return acknowledgment({
+ return rejectJoin('password_required', {
statusCode: 403,
message: 'Room requires password to join',
- }, roomMask(room));
+ }, room);
}
if (room.private && !(await room.authenticate(password))) {
- return acknowledgment({
+ return rejectJoin('invalid_password', {
statusCode: 403,
message: 'Invalid password',
- }, roomMask(room));
+ }, room);
}
if (socket.userId && room.banned.get(socket.userId.toString())) {
logger.debug(`Banned user ${socket.user.id} is trying to join room ${room._id}`);
- return acknowledgment({
+ return rejectJoin('banned', {
statusCode: 401,
message: 'Banned',
banned: true,
- }, roomMask(room));
+ }, room);
}
if (room.requireRevealedIdentity && (!socket.user || !socket.user.showWCAID)) {
- return acknowledgment({
+ return rejectJoin('identity_required', {
statusCode: 403,
message: 'Must be showing WCA Identity to join room.',
- }, roomMask(room));
+ }, room);
}
return await joinRoom(room, acknowledgment, spectating);
} catch (e) {
logger.error(e);
- return acknowledgment({
+ return rejectJoin('internal_error', {
statusCode: 500,
message: 'Failed to join room',
});
@@ -403,10 +514,10 @@ module.exports = (io, middlewares) => {
});
}
- if (options.type === 'grand_prix') {
+ if (options.type === 'grand_prix' && !config.grandPrix.enabled) {
return acknowledgment({
statusCode: 403,
- message: 'Not allowed to make a Grand Prix Room',
+ message: 'Grand Prix rooms are disabled',
});
}
@@ -428,6 +539,10 @@ module.exports = (io, middlewares) => {
newRoom.owner = socket.user;
const room = await newRoom.save();
+ await metrics.recordRoomCreated({
+ room,
+ userId: socket.userId,
+ });
ns().emit(Protocol.ROOM_CREATED, roomMask(room));
return joinRoom(room, (err, r) => {
if (err) {
@@ -463,6 +578,7 @@ module.exports = (io, middlewares) => {
const res = await Room.deleteOne({ _id: room._id });
if (res.deletedCount > 0) {
+ await markRoomDeleted(room._id);
socket.room = undefined;
acknowledgment(null);
ns().emit(Protocol.ROOM_DELETED, id);
@@ -519,43 +635,89 @@ module.exports = (io, middlewares) => {
}
});
- on(Protocol.SUBMIT_RESULT, async ({ id, result }) => {
- if (!socket.user || !socket.roomId) {
- return;
- }
-
- try {
- if (!socket.room.attempts[id]) {
- socket.emit(Protocol.ERROR, {
- statusCode: 400,
+ on(Protocol.SUBMIT_RESULT, async (payload = {}, cb) => {
+ const acknowledgment = optionalAcknowledgment(cb);
+ const rejectSubmission = (error) => {
+ if (typeof cb === 'function') {
+ return acknowledgment(error);
+ }
+ if (socket.connected) {
+ return socket.emit(Protocol.ERROR, {
+ ...error,
event: Protocol.SUBMIT_RESULT,
- message: 'Invalid ID for attempt submission',
});
- return;
- }
-
- if (socket.room.type === 'grand_prix') {
- result.penalties.DNF = result.penalties.DNF
- || id < socket.room.attempts.length - 1;
}
- socket.room.attempts[id].results.set(socket.user.id.toString(), result);
- socket.room.waitingFor.set(socket.user.id.toString(), false);
+ return undefined;
+ };
- const r = await socket.room.save();
+ if (!socket.user) {
+ return rejectSubmission({
+ statusCode: 401,
+ message: 'Must be logged in to submit a result',
+ retryable: false,
+ });
+ }
+ if (!socket.roomId) {
+ return rejectSubmission({
+ statusCode: 409,
+ message: 'Must rejoin the room before submitting a result',
+ retryable: true,
+ });
+ }
- ns().in(r.accessCode).emit(Protocol.NEW_RESULT, {
- id,
- result,
+ const {
+ id, attemptKey, result, submissionId,
+ } = payload || {};
+ let submission;
+ try {
+ submission = await resultSubmissions.submit({
+ roomId: socket.roomId,
userId: socket.user.id,
+ attemptId: id,
+ attemptKey,
+ result,
+ submissionId,
+ onSaved: ({ room, result: savedResult }) => {
+ socket.room = room;
+ try {
+ ns().in(room.accessCode).emit(Protocol.NEW_RESULT, {
+ id,
+ result: savedResult,
+ userId: socket.user.id,
+ });
+ } catch (err) {
+ logger.error(err);
+ }
+ Promise.resolve()
+ .then(() => metrics.recordRoomResult({
+ room,
+ userId: socket.userId,
+ }))
+ .catch((err) => logger.error(err));
+ },
});
-
- if (r.doneWithScramble()) {
- logger.debug('everyone done, sending new scramble');
- sendNewScramble(r);
+ } catch (err) {
+ if (err.cause) {
+ logger.error(err.cause);
+ } else if (!(err instanceof ResultSubmissionError)) {
+ logger.error(err);
}
- } catch (e) {
- logger.error(e);
+ const error = err instanceof ResultSubmissionError
+ ? err.toResponse()
+ : {
+ statusCode: 500,
+ message: 'Failed to save result',
+ retryable: true,
+ };
+ return rejectSubmission(error);
}
+
+ socket.room = submission.room;
+ acknowledgment(null, {
+ submissionId: submission.submissionId,
+ status: submission.status,
+ });
+ return undefined;
});
on(Protocol.SEND_EDIT_RESULT, async (result) => {
@@ -600,6 +762,14 @@ module.exports = (io, middlewares) => {
if (!checkAdmin()) {
return;
}
+ if (!isRoomTypeEnabled(socket.room.type)) {
+ socket.emit(Protocol.ERROR, {
+ statusCode: 403,
+ event: Protocol.REQUEST_SCRAMBLE,
+ message: 'Grand Prix rooms are disabled',
+ });
+ return;
+ }
sendNewScramble(socket.room);
});
@@ -608,6 +778,14 @@ module.exports = (io, middlewares) => {
if (!checkAdmin()) {
return;
}
+ if (!isRoomTypeEnabled(socket.room.type)) {
+ socket.emit(Protocol.ERROR, {
+ statusCode: 403,
+ event: Protocol.CHANGE_EVENT,
+ message: 'Grand Prix rooms are disabled',
+ });
+ return;
+ }
socket.room.changeEvent(event).then((r) => {
ns().in(r.accessCode).emit(Protocol.UPDATE_ROOM, joinRoomMask(socket.room));
@@ -619,8 +797,13 @@ module.exports = (io, middlewares) => {
return;
}
- if (options.type === 'grand_prix') {
+ if (options.type === 'grand_prix' && !config.grandPrix.enabled) {
logger.error(`${socket.id} attempted to edit room to grand_prix`);
+ socket.emit(Protocol.ERROR, {
+ statusCode: 403,
+ event: Protocol.EDIT_ROOM,
+ message: 'Grand Prix rooms are disabled',
+ });
return;
}
@@ -629,7 +812,10 @@ module.exports = (io, middlewares) => {
ns().in(room.accessCode).emit(Protocol.UPDATE_ROOM, joinRoomMask(room));
Room.find().then((rooms) => {
- ns().emit(Protocol.UPDATE_ROOMS, rooms.map(roomMask));
+ ns().emit(
+ Protocol.UPDATE_ROOMS,
+ rooms.filter((candidate) => isRoomTypeEnabled(candidate.type)).map(roomMask),
+ );
});
} catch (e) {
(logger.error(e));
@@ -640,6 +826,14 @@ module.exports = (io, middlewares) => {
if (!checkAdmin()) {
return;
}
+ if (!isRoomTypeEnabled(socket.room.type)) {
+ socket.emit(Protocol.ERROR, {
+ statusCode: 403,
+ event: Protocol.START_ROOM,
+ message: 'Grand Prix rooms are disabled',
+ });
+ return;
+ }
const room = await socket.room.start();
try {
@@ -668,6 +862,13 @@ module.exports = (io, middlewares) => {
try {
const room = await socket.room.dropUser({ id: userId });
+ await metrics.endRoomVisit({
+ room,
+ userId,
+ leaveReason: 'kick',
+ activeUserCount: room.usersLength,
+ });
+
ns().in(encodeUserRoom(userId, socket.room._id)).emit(Protocol.KICKED);
removeUserFromRoomSockets(ns(), userId, socket.room);
@@ -676,7 +877,7 @@ module.exports = (io, middlewares) => {
}
ns().in(socket.room.accessCode).emit(Protocol.USER_LEFT, userId);
- ns().emit(Protocol.GLOBAL_ROOM_UPDATED, roomMask(room));
+ sendGlobalRoomUpdate(room);
if (room.doneWithScramble()) {
logger.debug('everyone done, sending new scramble');
@@ -695,6 +896,13 @@ module.exports = (io, middlewares) => {
try {
const room = await socket.room.banUser(userId);
+ await metrics.endRoomVisit({
+ room,
+ userId,
+ leaveReason: 'ban',
+ activeUserCount: room.usersLength,
+ });
+
ns().in(encodeUserRoom(userId, socket.room._id)).emit(Protocol.BANNED);
removeUserFromRoomSockets(ns(), userId, socket.room);
@@ -703,7 +911,7 @@ module.exports = (io, middlewares) => {
}
ns().in(room.accessCode).emit(Protocol.UPDATE_ROOM, joinRoomMask(room));
- ns().emit(Protocol.GLOBAL_ROOM_UPDATED, roomMask(room));
+ sendGlobalRoomUpdate(room);
if (room.doneWithScramble()) {
logger.debug('everyone done, sending new scramble');
@@ -727,7 +935,7 @@ module.exports = (io, middlewares) => {
}
ns().in(room.accessCode).emit(Protocol.UPDATE_ROOM, joinRoomMask(room));
- ns().emit(Protocol.GLOBAL_ROOM_UPDATED, roomMask(room));
+ sendGlobalRoomUpdate(room);
} catch (e) {
logger.error(e);
}
@@ -765,7 +973,19 @@ module.exports = (io, middlewares) => {
logger.info(`socket ${socket.id} disconnected; Left room: ${socket.room ? socket.room.name : 'Null'}`, { roomId: socket.roomId });
if (socket.user && socket.room) {
- await leaveRoom();
+ reconnectGrace.schedule({
+ roomId: socket.roomId,
+ userId: socket.userId,
+ connectionId: socket.id,
+ leaveReason: 'disconnect',
+ });
+ } else if (socket.room) {
+ await metrics.endRoomVisit({
+ room: socket.room,
+ connectionId: socket.id,
+ leaveReason: 'disconnect',
+ activeUserCount: socket.room.usersLength,
+ });
}
updateClientsWithUsers();
@@ -777,8 +997,15 @@ module.exports = (io, middlewares) => {
on(Protocol.LEAVE_ROOM, async () => {
if (socket.room) {
if (socket.user) {
- await leaveRoom();
+ await leaveRoom('explicit');
socket.leave(encodeUserRoom(socket.userId, socket.room._id));
+ } else {
+ await metrics.endRoomVisit({
+ room: socket.room,
+ connectionId: socket.id,
+ leaveReason: 'explicit',
+ activeUserCount: socket.room.usersLength,
+ });
}
socket.leave(socket.room.accessCode);
diff --git a/yarn.lock b/yarn.lock
index adaf1c0..2a44c39 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1554,6 +1554,21 @@
debug "^3.1.0"
lodash.once "^4.1.1"
+"@electric-sql/pglite-socket@0.1.1":
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/@electric-sql/pglite-socket/-/pglite-socket-0.1.1.tgz#af443da3a60130aee254faee4daf50fb79fb8d1f"
+ integrity sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==
+
+"@electric-sql/pglite-tools@0.3.1":
+ version "0.3.1"
+ resolved "https://registry.yarnpkg.com/@electric-sql/pglite-tools/-/pglite-tools-0.3.1.tgz#b1b23dc45dcce22fb4d5a0505ba063923d09c105"
+ integrity sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==
+
+"@electric-sql/pglite@0.4.1":
+ version "0.4.1"
+ resolved "https://registry.yarnpkg.com/@electric-sql/pglite/-/pglite-0.4.1.tgz#a113476c3c20539756a8d77eb86d248d84a8d097"
+ integrity sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==
+
"@emnapi/core@1.11.1":
version "1.11.1"
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.11.1.tgz#b9e1064f3a6b1631e241e638eb48d736bfd372a6"
@@ -1581,6 +1596,11 @@
resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.8.0.tgz#bbbff68978fefdbe68ccb533bc8cbe1d1afb5413"
integrity sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==
+"@hono/node-server@1.19.11", "@hono/node-server@1.19.13":
+ version "1.19.13"
+ resolved "https://registry.yarnpkg.com/@hono/node-server/-/node-server-1.19.13.tgz#4838c766a1237253d4dde3281cf7d5c65186fd32"
+ integrity sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==
+
"@ioredis/commands@1.10.0":
version "1.10.0"
resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.10.0.tgz#cc387f8ec5ebe5b3b5104d393b5ac1f9cf794b9a"
@@ -1940,6 +1960,11 @@
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
+"@kurkle/color@^0.3.0":
+ version "0.3.4"
+ resolved "https://registry.yarnpkg.com/@kurkle/color/-/color-0.3.4.tgz#4d4ff677e1609214fc71c580125ddddd86abcabf"
+ integrity sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==
+
"@material-ui/core@^4.11.2":
version "4.12.4"
resolved "https://registry.yarnpkg.com/@material-ui/core/-/core-4.12.4.tgz#4ac17488e8fcaf55eb6a7f5efb2a131e10138a73"
@@ -2041,6 +2066,163 @@
resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.139.0.tgz#38d76b9dbf934c2a02be174fb32ceebf182fe742"
integrity sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==
+"@prisma/config@7.8.0":
+ version "7.8.0"
+ resolved "https://registry.yarnpkg.com/@prisma/config/-/config-7.8.0.tgz#401f1f108f2e463e508ac20ca08979d4ee215c65"
+ integrity sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==
+ dependencies:
+ c12 "3.3.4"
+ deepmerge-ts "7.1.5"
+ effect "3.20.0"
+ empathic "2.0.0"
+
+"@prisma/debug@7.2.0":
+ version "7.2.0"
+ resolved "https://registry.yarnpkg.com/@prisma/debug/-/debug-7.2.0.tgz#569b1cbc10eb3e8cae798b40075fd11d21f6b533"
+ integrity sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==
+
+"@prisma/debug@7.8.0":
+ version "7.8.0"
+ resolved "https://registry.yarnpkg.com/@prisma/debug/-/debug-7.8.0.tgz#8e2f70d284b3091c2d713aa093a0f5898487e431"
+ integrity sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==
+
+"@prisma/dev@0.24.3":
+ version "0.24.3"
+ resolved "https://registry.yarnpkg.com/@prisma/dev/-/dev-0.24.3.tgz#a235c2cfca28134f904e6b964d7652a9dfbd60f4"
+ integrity sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==
+ dependencies:
+ "@electric-sql/pglite" "0.4.1"
+ "@electric-sql/pglite-socket" "0.1.1"
+ "@electric-sql/pglite-tools" "0.3.1"
+ "@hono/node-server" "1.19.11"
+ "@prisma/get-platform" "7.2.0"
+ "@prisma/query-plan-executor" "7.2.0"
+ "@prisma/streams-local" "0.1.2"
+ foreground-child "3.3.1"
+ get-port-please "3.2.0"
+ hono "^4.12.8"
+ http-status-codes "2.3.0"
+ pathe "2.0.3"
+ proper-lockfile "4.1.2"
+ remeda "2.33.4"
+ std-env "3.10.0"
+ valibot "1.2.0"
+ zeptomatch "2.1.0"
+
+"@prisma/engines-version@7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a":
+ version "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a"
+ resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a.tgz#6ab01f7c2619a9f9f1634418288a08080a630d18"
+ integrity sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==
+
+"@prisma/engines@7.8.0":
+ version "7.8.0"
+ resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-7.8.0.tgz#214c778871929bcf96a056285270ddf1d74e7051"
+ integrity sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==
+ dependencies:
+ "@prisma/debug" "7.8.0"
+ "@prisma/engines-version" "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a"
+ "@prisma/fetch-engine" "7.8.0"
+ "@prisma/get-platform" "7.8.0"
+
+"@prisma/fetch-engine@7.8.0":
+ version "7.8.0"
+ resolved "https://registry.yarnpkg.com/@prisma/fetch-engine/-/fetch-engine-7.8.0.tgz#699c1876d862b1f965b0318eb5e80b6c3744aa3f"
+ integrity sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==
+ dependencies:
+ "@prisma/debug" "7.8.0"
+ "@prisma/engines-version" "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a"
+ "@prisma/get-platform" "7.8.0"
+
+"@prisma/get-platform@7.2.0":
+ version "7.2.0"
+ resolved "https://registry.yarnpkg.com/@prisma/get-platform/-/get-platform-7.2.0.tgz#b3a92db68de6a76e840e61d2f26659aa9f915e3e"
+ integrity sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==
+ dependencies:
+ "@prisma/debug" "7.2.0"
+
+"@prisma/get-platform@7.8.0":
+ version "7.8.0"
+ resolved "https://registry.yarnpkg.com/@prisma/get-platform/-/get-platform-7.8.0.tgz#7b1dade4117939c68c1324150ac72773f7272e68"
+ integrity sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==
+ dependencies:
+ "@prisma/debug" "7.8.0"
+
+"@prisma/query-plan-executor@7.2.0":
+ version "7.2.0"
+ resolved "https://registry.yarnpkg.com/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz#00b218d78066957f25ccae0954bbef708396cc9f"
+ integrity sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==
+
+"@prisma/streams-local@0.1.2":
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/@prisma/streams-local/-/streams-local-0.1.2.tgz#531679bf13aafe4c663778848ff61a106b95df27"
+ integrity sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==
+ dependencies:
+ ajv "^8.12.0"
+ better-result "^2.7.0"
+ env-paths "^3.0.0"
+ proper-lockfile "^4.1.2"
+
+"@prisma/studio-core@0.27.3":
+ version "0.27.3"
+ resolved "https://registry.yarnpkg.com/@prisma/studio-core/-/studio-core-0.27.3.tgz#45246d76565ada1728bc7e0d29217c0e4746a631"
+ integrity sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==
+ dependencies:
+ "@radix-ui/react-toggle" "1.1.10"
+ chart.js "4.5.1"
+
+"@radix-ui/primitive@1.1.3":
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.3.tgz#e2dbc13bdc5e4168f4334f75832d7bdd3e2de5ba"
+ integrity sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==
+
+"@radix-ui/react-compose-refs@1.1.2":
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz#a2c4c47af6337048ee78ff6dc0d090b390d2bb30"
+ integrity sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==
+
+"@radix-ui/react-primitive@2.1.3":
+ version "2.1.3"
+ resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz#db9b8bcff49e01be510ad79893fb0e4cda50f1bc"
+ integrity sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==
+ dependencies:
+ "@radix-ui/react-slot" "1.2.3"
+
+"@radix-ui/react-slot@1.2.3":
+ version "1.2.3"
+ resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz#502d6e354fc847d4169c3bc5f189de777f68cfe1"
+ integrity sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==
+ dependencies:
+ "@radix-ui/react-compose-refs" "1.1.2"
+
+"@radix-ui/react-toggle@1.1.10":
+ version "1.1.10"
+ resolved "https://registry.yarnpkg.com/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz#b04ba0f9609599df666fce5b2f38109a197f08cf"
+ integrity sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==
+ dependencies:
+ "@radix-ui/primitive" "1.1.3"
+ "@radix-ui/react-primitive" "2.1.3"
+ "@radix-ui/react-use-controllable-state" "1.2.2"
+
+"@radix-ui/react-use-controllable-state@1.2.2":
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz#905793405de57d61a439f4afebbb17d0645f3190"
+ integrity sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==
+ dependencies:
+ "@radix-ui/react-use-effect-event" "0.0.2"
+ "@radix-ui/react-use-layout-effect" "1.1.1"
+
+"@radix-ui/react-use-effect-event@0.0.2":
+ version "0.0.2"
+ resolved "https://registry.yarnpkg.com/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz#090cf30d00a4c7632a15548512e9152217593907"
+ integrity sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==
+ dependencies:
+ "@radix-ui/react-use-layout-effect" "1.1.1"
+
+"@radix-ui/react-use-layout-effect@1.1.1":
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz#0c4230a9eed49d4589c967e2d9c0d9d60a23971e"
+ integrity sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==
+
"@rolldown/binding-android-arm64@1.1.5":
version "1.1.5"
resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz#f58cb9a0a8128ed0582282720528547fc5c035f3"
@@ -2389,6 +2571,11 @@
notepack.io "~3.0.1"
uid2 "1.0.0"
+"@standard-schema/spec@^1.0.0":
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8"
+ integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==
+
"@szmarczak/http-timer@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421"
@@ -2928,7 +3115,7 @@ ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.3:
json-schema-traverse "^0.4.1"
uri-js "^4.2.2"
-ajv@^8.6.0:
+ajv@^8.12.0, ajv@^8.6.0:
version "8.20.0"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.20.0.tgz#304b3636add88ba7d936760dd50ece006dea95f9"
integrity sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==
@@ -3288,6 +3475,11 @@ aws-sign2@~0.7.0:
resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
integrity sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==
+aws-ssl-profiles@^1.1.1:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz#157dd77e9f19b1d123678e93f120e6f193022641"
+ integrity sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==
+
aws4@^1.8.0:
version "1.13.2"
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.13.2.tgz#0aa167216965ac9474ccfa83892cfb6b3e1e52ef"
@@ -3541,6 +3733,11 @@ bcrypt@6.0.0:
node-addon-api "^8.3.0"
node-gyp-build "^4.8.4"
+better-result@^2.7.0:
+ version "2.9.2"
+ resolved "https://registry.yarnpkg.com/better-result/-/better-result-2.9.2.tgz#34da6e0e352bd44e4252acb88e2a4a35ec4c335e"
+ integrity sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==
+
binary-extensions@^2.0.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522"
@@ -3711,6 +3908,24 @@ bytes@~3.1.2:
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
+c12@3.3.4:
+ version "3.3.4"
+ resolved "https://registry.yarnpkg.com/c12/-/c12-3.3.4.tgz#1253a5faf8b61244884d42459b4a6412571fe9f3"
+ integrity sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==
+ dependencies:
+ chokidar "^5.0.0"
+ confbox "^0.2.4"
+ defu "^6.1.6"
+ dotenv "^17.3.1"
+ exsolve "^1.0.8"
+ giget "^3.2.0"
+ jiti "^2.6.1"
+ ohash "^2.0.11"
+ pathe "^2.0.3"
+ perfect-debounce "^2.1.0"
+ pkg-types "^2.3.0"
+ rc9 "^3.0.1"
+
cache-base@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2"
@@ -3852,6 +4067,13 @@ chardet@^0.7.0:
resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e"
integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==
+chart.js@4.5.1:
+ version "4.5.1"
+ resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-4.5.1.tgz#19dd1a9a386a3f6397691672231cb5fc9c052c35"
+ integrity sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==
+ dependencies:
+ "@kurkle/color" "^0.3.0"
+
cheerio-select@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/cheerio-select/-/cheerio-select-2.1.0.tgz#4d8673286b8126ca2a8e42740d5e3c4884ae21b4"
@@ -3892,6 +4114,13 @@ chokidar@^3.2.2:
optionalDependencies:
fsevents "~2.3.2"
+chokidar@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-5.0.0.tgz#949c126a9238a80792be9a0265934f098af369a5"
+ integrity sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==
+ dependencies:
+ readdirp "^5.0.0"
+
ci-info@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46"
@@ -4122,6 +4351,11 @@ concat-map@0.0.1:
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
+confbox@^0.2.4:
+ version "0.2.4"
+ resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.2.4.tgz#592e7be71f882a4a874e3c88f0ac1ef6f7da1ce5"
+ integrity sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==
+
configstore@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/configstore/-/configstore-5.0.1.tgz#d365021b5df4b98cdd187d6a3b0e3f6a7cc5ed96"
@@ -4538,6 +4772,11 @@ deep-is@~0.1.3:
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
+deepmerge-ts@7.1.5:
+ version "7.1.5"
+ resolved "https://registry.yarnpkg.com/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz#ff818564007f5c150808d2b7b732cac83aa415ab"
+ integrity sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==
+
deepmerge@^4.2.2:
version "4.3.1"
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
@@ -4588,12 +4827,17 @@ define-property@^2.0.2:
is-descriptor "^1.0.2"
isobject "^3.0.1"
+defu@^6.1.6:
+ version "6.1.7"
+ resolved "https://registry.yarnpkg.com/defu/-/defu-6.1.7.tgz#72543567c8e9f97ff13ce402b6dbe09ac5ae4d23"
+ integrity sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==
+
delayed-stream@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
-denque@2.1.0:
+denque@2.1.0, denque@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/denque/-/denque-2.1.0.tgz#e93e1a6569fb5e66f16a3c2a2964617d349d6ab1"
integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==
@@ -4613,6 +4857,11 @@ dequal@^2.0.3:
resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be"
integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==
+destr@^2.0.5:
+ version "2.0.5"
+ resolved "https://registry.yarnpkg.com/destr/-/destr-2.0.5.tgz#7d112ff1b925fb8d2079fac5bdb4a90973b51fdb"
+ integrity sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==
+
destroy@1.2.0, destroy@~1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015"
@@ -4753,6 +5002,11 @@ dotenv@8.2.0:
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.2.0.tgz#97e619259ada750eea3e4ea3e26bceea5424b16a"
integrity sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==
+dotenv@^17.3.1:
+ version "17.4.2"
+ resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-17.4.2.tgz#c07e54a746e11eba021dd9e1047ced5afdc1c034"
+ integrity sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==
+
dunder-proto@^1.0.0, dunder-proto@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a"
@@ -4780,6 +5034,14 @@ ee-first@1.1.1:
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==
+effect@3.20.0:
+ version "3.20.0"
+ resolved "https://registry.yarnpkg.com/effect/-/effect-3.20.0.tgz#827752d2c90f0a12562f1fdac3bf0197d067fd6a"
+ integrity sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==
+ dependencies:
+ "@standard-schema/spec" "^1.0.0"
+ fast-check "^3.23.1"
+
ejs@^3.1.10:
version "3.1.10"
resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.10.tgz#69ab8358b14e896f80cc39e62087b88500c3ac3b"
@@ -4812,6 +5074,11 @@ emoji-regex@^8.0.0:
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
+empathic@2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/empathic/-/empathic-2.0.0.tgz#71d3c2b94fad49532ef98a6c34be0386659f6131"
+ integrity sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==
+
enabled@1.0.x:
version "1.0.2"
resolved "https://registry.yarnpkg.com/enabled/-/enabled-1.0.2.tgz#965f6513d2c2d1c5f4652b64a2e3396467fc2f93"
@@ -4873,6 +5140,11 @@ entities@^6.0.0:
resolved "https://registry.yarnpkg.com/entities/-/entities-6.0.1.tgz#c28c34a43379ca7f61d074130b2f5f7020a30694"
integrity sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==
+env-paths@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-3.0.0.tgz#2f1e89c2f6dbd3408e1b1711dd82d62e317f58da"
+ integrity sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==
+
env-variable@0.0.x:
version "0.0.6"
resolved "https://registry.yarnpkg.com/env-variable/-/env-variable-0.0.6.tgz#74ab20b3786c545b62b4a4813ab8cf22726c9808"
@@ -5564,6 +5836,11 @@ express@^4.22.2:
utils-merge "1.0.1"
vary "~1.1.2"
+exsolve@^1.0.8:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/exsolve/-/exsolve-1.1.0.tgz#adefa9b18b3f3515e946d48eb2ca3bb0f2c51b4d"
+ integrity sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==
+
extend-shallow@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f"
@@ -5617,6 +5894,13 @@ extsprintf@^1.2.0:
resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.1.tgz#8d172c064867f235c0c84a596806d279bf4bcc07"
integrity sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==
+fast-check@^3.23.1:
+ version "3.23.2"
+ resolved "https://registry.yarnpkg.com/fast-check/-/fast-check-3.23.2.tgz#0129f1eb7e4f500f58e8290edc83c670e4a574a2"
+ integrity sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==
+ dependencies:
+ pure-rand "^6.1.0"
+
fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
@@ -5770,7 +6054,7 @@ for-in@^1.0.2:
resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
integrity sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==
-foreground-child@^3.3.1:
+foreground-child@3.3.1, foreground-child@^3.3.1:
version "3.3.1"
resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f"
integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==
@@ -5889,6 +6173,13 @@ functions-have-names@^1.2.3:
resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834"
integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
+generate-function@^2.3.1:
+ version "2.3.1"
+ resolved "https://registry.yarnpkg.com/generate-function/-/generate-function-2.3.1.tgz#f069617690c10c868e73b8465746764f97c3479f"
+ integrity sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==
+ dependencies:
+ is-property "^1.0.2"
+
generator-function@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/generator-function/-/generator-function-2.0.1.tgz#0e75dd410d1243687a0ba2e951b94eedb8f737a2"
@@ -5935,6 +6226,11 @@ get-package-type@^0.1.0:
resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a"
integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==
+get-port-please@3.2.0:
+ version "3.2.0"
+ resolved "https://registry.yarnpkg.com/get-port-please/-/get-port-please-3.2.0.tgz#0ce3cee194c448ac640ec39dc357a500f5d7d2bb"
+ integrity sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==
+
get-proto@^1.0.0, get-proto@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1"
@@ -5988,6 +6284,11 @@ getpass@^0.1.1:
dependencies:
assert-plus "^1.0.0"
+giget@^3.2.0:
+ version "3.3.0"
+ resolved "https://registry.yarnpkg.com/giget/-/giget-3.3.0.tgz#b70ee7a3c162c148867a8db918ee14d01b62dab7"
+ integrity sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==
+
glob-parent@^5.0.0, glob-parent@~5.1.2:
version "5.1.2"
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
@@ -6075,6 +6376,16 @@ graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
+grammex@^3.1.11:
+ version "3.1.12"
+ resolved "https://registry.yarnpkg.com/grammex/-/grammex-3.1.12.tgz#08f021dd2cad009e64248fb53247cc6108788404"
+ integrity sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==
+
+graphmatch@^1.1.0:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/graphmatch/-/graphmatch-1.1.1.tgz#dcec68e8cb74de0a372d5252fc06e241daf71c38"
+ integrity sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==
+
growly@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081"
@@ -6214,6 +6525,11 @@ hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-
dependencies:
react-is "^16.7.0"
+hono@^4.12.8:
+ version "4.12.28"
+ resolved "https://registry.yarnpkg.com/hono/-/hono-4.12.28.tgz#c2e12c32027f0e9fb8b0cef2ecfb73050c2ca62e"
+ integrity sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==
+
hosted-git-info@^2.1.4:
version "2.8.9"
resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9"
@@ -6299,6 +6615,11 @@ http-signature@~1.4.0:
jsprim "^2.0.2"
sshpk "^1.18.0"
+http-status-codes@2.3.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/http-status-codes/-/http-status-codes-2.3.0.tgz#987fefb28c69f92a43aecc77feec2866349a8bfc"
+ integrity sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==
+
https-proxy-agent@^5.0.0:
version "5.0.1"
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6"
@@ -6340,6 +6661,13 @@ iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@~0.4.24:
dependencies:
safer-buffer ">= 2.1.2 < 3"
+iconv-lite@^0.7.0:
+ version "0.7.3"
+ resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.7.3.tgz#84ee12f963e7de50bc01a13e160a078b3b0f415f"
+ integrity sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==
+ dependencies:
+ safer-buffer ">= 2.1.2 < 3.0.0"
+
idb@^7.0.1:
version "7.1.1"
resolved "https://registry.yarnpkg.com/idb/-/idb-7.1.1.tgz#d910ded866d32c7ced9befc5bfdf36f572ced72b"
@@ -6809,6 +7137,11 @@ is-potential-custom-element-name@^1.0.1:
resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5"
integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==
+is-property@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/is-property/-/is-property-1.0.2.tgz#57fe1c4e48474edd65b09911f26b1cd4095dda84"
+ integrity sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==
+
is-regex@^1.0.5, is-regex@^1.1.0, is-regex@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.2.1.tgz#76d70a3ed10ef9be48eb577887d74205bf0cad22"
@@ -7601,6 +7934,11 @@ jest@^26.6.0, jest@^26.6.3:
import-local "^3.0.2"
jest-cli "^26.6.3"
+jiti@^2.6.1:
+ version "2.7.0"
+ resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.7.0.tgz#974228f2f4ca2bc21885a1797b45fea68e950c64"
+ integrity sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==
+
"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
@@ -8141,6 +8479,11 @@ logform@^2.1.1, logform@^2.7.0:
safe-stable-stringify "^2.3.1"
triple-beam "^1.3.0"
+long@^5.2.1:
+ version "5.3.2"
+ resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83"
+ integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==
+
loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
@@ -8170,6 +8513,11 @@ lru-cache@^5.1.1:
dependencies:
yallist "^3.0.2"
+lru.min@^1.0.0, lru.min@^1.1.0:
+ version "1.1.4"
+ resolved "https://registry.yarnpkg.com/lru.min/-/lru.min-1.1.4.tgz#6ea1737a8c1ba2300cc87ad46910a4bdffa0117b"
+ integrity sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==
+
lz-string@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941"
@@ -8499,6 +8847,28 @@ mute-stream@0.0.8:
resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d"
integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==
+mysql2@3.15.3:
+ version "3.15.3"
+ resolved "https://registry.yarnpkg.com/mysql2/-/mysql2-3.15.3.tgz#f0348d9c7401bb98cb1f45ffc5a773b109f70808"
+ integrity sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==
+ dependencies:
+ aws-ssl-profiles "^1.1.1"
+ denque "^2.1.0"
+ generate-function "^2.3.1"
+ iconv-lite "^0.7.0"
+ long "^5.2.1"
+ lru.min "^1.0.0"
+ named-placeholders "^1.1.3"
+ seq-queue "^0.0.5"
+ sqlstring "^2.3.2"
+
+named-placeholders@^1.1.3:
+ version "1.1.6"
+ resolved "https://registry.yarnpkg.com/named-placeholders/-/named-placeholders-1.1.6.tgz#c50c6920b43f258f59c16add1e56654f5cc02bb5"
+ integrity sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==
+ dependencies:
+ lru.min "^1.1.0"
+
nan@^2.12.1:
version "2.28.0"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.28.0.tgz#126717fd359d5a03d3edf7c44e6ce9b707fb57f5"
@@ -8787,6 +9157,11 @@ object.values@^1.1.0, object.values@^1.1.1, object.values@^1.1.6, object.values@
define-properties "^1.2.1"
es-object-atoms "^1.0.0"
+ohash@^2.0.11:
+ version "2.0.11"
+ resolved "https://registry.yarnpkg.com/ohash/-/ohash-2.0.11.tgz#60b11e8cff62ca9dee88d13747a5baa145f5900b"
+ integrity sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==
+
on-finished@~2.4.1:
version "2.4.1"
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f"
@@ -9137,6 +9512,11 @@ path-type@^4.0.0:
resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
+pathe@2.0.3, pathe@^2.0.3:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716"
+ integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==
+
pause@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/pause/-/pause-0.0.1.tgz#1d408b3fdb76923b9543d96fb4c9dfd535d9cb5d"
@@ -9147,11 +9527,72 @@ pend@~1.2.0:
resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==
+perfect-debounce@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/perfect-debounce/-/perfect-debounce-2.1.0.tgz#e7078e38f231cb191855c3136a4423aef725d261"
+ integrity sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==
+
performance-now@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
integrity sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==
+pg-cloudflare@^1.4.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz#4b4c20e6d8ae531d400730f4804571a8d62f1497"
+ integrity sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==
+
+pg-connection-string@^2.14.0:
+ version "2.14.0"
+ resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.14.0.tgz#abc26ee4f37c56c0f3ae0fcf0b0653cc4e1c0fd9"
+ integrity sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==
+
+pg-int8@1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c"
+ integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==
+
+pg-pool@^3.14.0:
+ version "3.14.0"
+ resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.14.0.tgz#f35ae4eb846780cad71af24099b3edfa9781ad90"
+ integrity sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==
+
+pg-protocol@^1.15.0:
+ version "1.15.0"
+ resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.15.0.tgz#758f6c0679cc0bbf4938603b7597703f333180c0"
+ integrity sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==
+
+pg-types@2.2.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/pg-types/-/pg-types-2.2.0.tgz#2d0250d636454f7cfa3b6ae0382fdfa8063254a3"
+ integrity sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==
+ dependencies:
+ pg-int8 "1.0.1"
+ postgres-array "~2.0.0"
+ postgres-bytea "~1.0.0"
+ postgres-date "~1.0.4"
+ postgres-interval "^1.1.0"
+
+pg@8.22.0:
+ version "8.22.0"
+ resolved "https://registry.yarnpkg.com/pg/-/pg-8.22.0.tgz#55ca3975026180c6dced6eec3a20a844c2dc9237"
+ integrity sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==
+ dependencies:
+ pg-connection-string "^2.14.0"
+ pg-pool "^3.14.0"
+ pg-protocol "^1.15.0"
+ pg-types "2.2.0"
+ pgpass "1.0.5"
+ optionalDependencies:
+ pg-cloudflare "^1.4.0"
+
+pgpass@1.0.5:
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/pgpass/-/pgpass-1.0.5.tgz#9b873e4a564bb10fa7a7dbd55312728d422a223d"
+ integrity sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==
+ dependencies:
+ split2 "^4.1.0"
+
picocolors@1.1.1, picocolors@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
@@ -9189,6 +9630,15 @@ pkg-dir@^4.2.0:
dependencies:
find-up "^4.0.0"
+pkg-types@^2.3.0:
+ version "2.3.1"
+ resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-2.3.1.tgz#fa27ed0940efcf40bba453b0e5cab41217b0d442"
+ integrity sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==
+ dependencies:
+ confbox "^0.2.4"
+ exsolve "^1.0.8"
+ pathe "^2.0.3"
+
please-upgrade-node@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz#aeddd3f994c933e4ad98b99d9a556efa0e2fe942"
@@ -9225,6 +9675,33 @@ postcss@^8.5.16:
picocolors "^1.1.1"
source-map-js "^1.2.1"
+postgres-array@~2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/postgres-array/-/postgres-array-2.0.0.tgz#48f8fce054fbc69671999329b8834b772652d82e"
+ integrity sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==
+
+postgres-bytea@~1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/postgres-bytea/-/postgres-bytea-1.0.1.tgz#c40b3da0222c500ff1e51c5d7014b60b79697c7a"
+ integrity sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==
+
+postgres-date@~1.0.4:
+ version "1.0.7"
+ resolved "https://registry.yarnpkg.com/postgres-date/-/postgres-date-1.0.7.tgz#51bc086006005e5061c591cee727f2531bf641a8"
+ integrity sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==
+
+postgres-interval@^1.1.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/postgres-interval/-/postgres-interval-1.2.0.tgz#b460c82cb1587507788819a06aa0fffdb3544695"
+ integrity sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==
+ dependencies:
+ xtend "^4.0.0"
+
+postgres@3.4.7:
+ version "3.4.7"
+ resolved "https://registry.yarnpkg.com/postgres/-/postgres-3.4.7.tgz#122f460a808fe300cae53f592108b9906e625345"
+ integrity sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==
+
prelude-ls@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
@@ -9291,6 +9768,18 @@ pretty-format@^27.0.2, pretty-format@^27.5.1:
ansi-styles "^5.0.0"
react-is "^17.0.1"
+prisma@7.8.0:
+ version "7.8.0"
+ resolved "https://registry.yarnpkg.com/prisma/-/prisma-7.8.0.tgz#f64db69e59131fe10859efb5969af1c7e50e6620"
+ integrity sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==
+ dependencies:
+ "@prisma/config" "7.8.0"
+ "@prisma/dev" "0.24.3"
+ "@prisma/engines" "7.8.0"
+ "@prisma/studio-core" "0.27.3"
+ mysql2 "3.15.3"
+ postgres "3.4.7"
+
process-nextick-args@~2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
@@ -9351,6 +9840,15 @@ prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
object-assign "^4.1.1"
react-is "^16.13.1"
+proper-lockfile@4.1.2, proper-lockfile@^4.1.2:
+ version "4.1.2"
+ resolved "https://registry.yarnpkg.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz#c8b9de2af6b2f1601067f98e01ac66baa223141f"
+ integrity sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==
+ dependencies:
+ graceful-fs "^4.2.4"
+ retry "^0.12.0"
+ signal-exit "^3.0.2"
+
property-information@^5.3.0:
version "5.6.0"
resolved "https://registry.yarnpkg.com/property-information/-/property-information-5.6.0.tgz#61675545fb23002f245c6540ec46077d4da3ed69"
@@ -9403,6 +9901,11 @@ pupa@^2.0.1:
dependencies:
escape-goat "^2.0.0"
+pure-rand@^6.1.0:
+ version "6.1.0"
+ resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.1.0.tgz#d173cf23258231976ccbdb05247c9787957604f2"
+ integrity sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==
+
qs@^6.15.2, qs@^6.9.6, qs@~6.15.1:
version "6.15.3"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.3.tgz#76852132a58ed5c7c0ef67e4441b9bb5d6061b3b"
@@ -9461,6 +9964,14 @@ raw-body@~2.5.3:
iconv-lite "~0.4.24"
unpipe "~1.0.0"
+rc9@^3.0.1:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/rc9/-/rc9-3.0.1.tgz#3895e5834a2b5c2d8fb76d93e802fbcbc2579bc7"
+ integrity sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==
+ dependencies:
+ defu "^6.1.6"
+ destr "^2.0.5"
+
rc@1.2.8, rc@^1.2.8:
version "1.2.8"
resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
@@ -9698,6 +10209,11 @@ readable-stream@^3.1.1, readable-stream@^3.6.2:
string_decoder "^1.1.1"
util-deprecate "^1.0.1"
+readdirp@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-5.0.0.tgz#fbf1f71a727891d685bb1786f9ba74084f6e2f91"
+ integrity sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==
+
readdirp@~3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
@@ -9857,6 +10373,11 @@ remark-rehype@^8.0.0:
dependencies:
mdast-util-to-hast "^10.2.0"
+remeda@2.33.4:
+ version "2.33.4"
+ resolved "https://registry.yarnpkg.com/remeda/-/remeda-2.33.4.tgz#eae3bb2ec9795db58a1b66249913772a1a2c7989"
+ integrity sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==
+
remove-trailing-separator@^1.0.1:
version "1.1.0"
resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef"
@@ -10033,6 +10554,11 @@ ret@~0.1.10:
resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc"
integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==
+retry@^0.12.0:
+ version "0.12.0"
+ resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b"
+ integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==
+
rfdc@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.4.1.tgz#778f76c4fb731d93414e8f925fbecf64cce7f6ca"
@@ -10185,7 +10711,7 @@ safe-stable-stringify@^2.3.1:
resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz#4ca2f8e385f2831c432a719b108a3bf7af42a1dd"
integrity sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==
-"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0:
+"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0:
version "2.1.2"
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
@@ -10292,6 +10818,11 @@ send@~0.19.0, send@~0.19.1:
range-parser "~1.2.1"
statuses "~2.0.2"
+seq-queue@^0.0.5:
+ version "0.0.5"
+ resolved "https://registry.yarnpkg.com/seq-queue/-/seq-queue-0.0.5.tgz#d56812e1c017a6e4e7c3e3a37a1da6d78dd3c93e"
+ integrity sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==
+
serialize-javascript@^7.0.3:
version "7.0.7"
resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-7.0.7.tgz#06ec40576d4cea96d68010a534520bff1f948a72"
@@ -10677,11 +11208,21 @@ split-string@^3.0.1, split-string@^3.0.2:
dependencies:
extend-shallow "^3.0.0"
+split2@^4.1.0:
+ version "4.2.0"
+ resolved "https://registry.yarnpkg.com/split2/-/split2-4.2.0.tgz#c9c5920904d148bab0b9f67145f245a86aadbfa4"
+ integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==
+
sprintf-js@~1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==
+sqlstring@^2.3.2:
+ version "2.3.3"
+ resolved "https://registry.yarnpkg.com/sqlstring/-/sqlstring-2.3.3.tgz#2ddc21f03bce2c387ed60680e739922c65751d0c"
+ integrity sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==
+
sshpk@^1.18.0, sshpk@^1.7.0:
version "1.18.0"
resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.18.0.tgz#1663e55cddf4d688b86a46b77f0d5fe363aba028"
@@ -10739,6 +11280,11 @@ statuses@~2.0.1, statuses@~2.0.2:
resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382"
integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==
+std-env@3.10.0:
+ version "3.10.0"
+ resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.10.0.tgz#d810b27e3a073047b2b5e40034881f5ea6f9c83b"
+ integrity sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==
+
stealthy-require@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b"
@@ -11676,6 +12222,11 @@ v8-to-istanbul@^7.0.0:
convert-source-map "^1.6.0"
source-map "^0.7.3"
+valibot@1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/valibot/-/valibot-1.2.0.tgz#8fc720d9e4082ba16e30a914064a39619b2f1d6f"
+ integrity sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==
+
validate-npm-package-license@^3.0.1:
version "3.0.4"
resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a"
@@ -12251,3 +12802,11 @@ yauzl@^3.3.1:
integrity sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==
dependencies:
pend "~1.2.0"
+
+zeptomatch@2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/zeptomatch/-/zeptomatch-2.1.0.tgz#cca2cb2c61308d0c26f9689e6640f6335d0f2101"
+ integrity sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==
+ dependencies:
+ grammex "^3.1.11"
+ graphmatch "^1.1.0"