Skip to content

Commit e3ce880

Browse files
committed
Add RaceSession PostgreSQL foundation
Project normal-room sessions and expose development-only PostgreSQL solve history while preserving MongoDB read authority.
1 parent 2bc6706 commit e3ce880

15 files changed

Lines changed: 740 additions & 36 deletions

File tree

README.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -104,17 +104,19 @@ access codes, OAuth credentials, chat content, scramble text, or solve times.
104104

105105
New MongoDB writes are mirrored into PostgreSQL without changing application
106106
reads. PostgreSQL receives public identity and preferences, rooms and
107-
participant state, attempts, durable solve results, and sanitized analytics
108-
events. OAuth access tokens are deliberately not copied. Writes use
107+
participant state, RaceSession projections for normal rooms, attempts, durable
108+
solve results, and sanitized analytics events. OAuth access tokens are deliberately not copied. Writes use
109109
deterministic UUIDs and upserts,
110110
so retries and future backfills are idempotent. Live room saves mirror only the
111111
attempts and results changed by that save; complete room snapshots are reserved
112-
for explicit backfills. Changing a room event explicitly replaces that room's
113-
PostgreSQL attempts so removed MongoDB attempts do not remain queryable.
112+
for explicit backfills. Changing a normal room event ends its projected
113+
RaceSession and starts a new one; earlier attempts and solves remain preserved
114+
under their earlier session.
114115

115116
Solve penalties use dedicated boolean columns rather than JSON so histories and
116-
statistics remain compact and index-friendly. User solve history is indexed by
117-
creation time and solve ID for stable cursor pagination.
117+
statistics remain compact and index-friendly. Development-only
118+
`GET /api/solve-history` reads PostgreSQL session-linked history for the
119+
authenticated participant and is explicitly disabled in production.
118120

119121
Set `POSTGRES_ENABLED=false` to disable mirroring. Production should set
120122
`PGHOST`, `PGDATABASE`, `PGUSER`, and `POSTGRES_PASSWORD`, or provide a

docs/data.md

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,26 +26,36 @@ Prisma defines PostgreSQL in `server/prisma/schema.prisma`, using two schemas:
2626

2727
| Schema | Contents |
2828
| --- | --- |
29-
| `app` | users, rooms, participants, attempts, and solves |
29+
| `app` | users, rooms, room/session participants, RaceSessions, attempts, and solves |
3030
| `analytics` | pseudonymous metric events |
3131

3232
PostgreSQL receives normalized copies of users/preferences, rooms and
33-
participant state, attempts, durable solves, and sanitized analytics. OAuth
34-
access tokens are deliberately not mirrored.
33+
participant state, normal-room RaceSession projections, attempts, durable
34+
solves, and sanitized analytics. OAuth access tokens are deliberately not
35+
mirrored. Existing PostgreSQL attempts and solves that predate the expansion
36+
remain unlinked until the backfill/reconciliation operation assigns them.
3537

36-
Application reads do not use PostgreSQL yet. Setting `POSTGRES_ENABLED=false`
37-
disables initialization and mirrors. PostgreSQL failures are logged, while API
38-
and Socket.IO health report the service as `degraded` instead of unavailable.
38+
Application reads remain MongoDB-backed except for the development-only solve
39+
history experiment. Setting `POSTGRES_ENABLED=false` disables initialization
40+
and mirrors. PostgreSQL failures are logged, while API and Socket.IO health
41+
report the service as `degraded` instead of unavailable.
3942

4043
## Dual-Write Guarantees
4144

4245
`server/postgres/dualWrite.js` derives stable UUIDs from Mongo/WCA identifiers
4346
and uses upserts so retries and future backfills remain idempotent.
4447

45-
Room saves collect changed attempts/results and mirror only that delta. An
46-
explicit event change replaces that room's PostgreSQL attempts so attempts
47-
removed from MongoDB do not remain queryable. Complete snapshots are reserved
48-
for explicit backfill behavior.
48+
Room saves collect changed attempts/results and mirror only that delta. A
49+
normal-room event change ends the current projected RaceSession and creates a
50+
new one, preserving the earlier session's attempts and solves. Complete
51+
snapshots are reserved for explicit backfill behavior.
52+
53+
`GET /api/solve-history` is a PostgreSQL-only, authenticated self-history
54+
endpoint enabled only when `NODE_ENV=development`. It returns only
55+
session-linked solves for a current non-banned room participant; hidden,
56+
expired, and soft-deleted rooms remain readable to that participant. It never
57+
falls back to MongoDB or selects access codes, passwords, membership lists,
58+
moderation data, or email data.
4959

5060
The mirror intentionally catches database failures and returns control to the
5161
MongoDB-backed request. Monitoring must surface mirror errors because users may

server/api.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const { updateUsername } = require('./username');
77
const createFriendsRouter = require('./api/friends');
88
const createNotificationsRouter = require('./api/notifications');
99
const createUsersRouter = require('./api/users');
10+
const { createSolveHistoryRouter } = require('./api/solveHistory');
1011
const { isFeatureEnabled } = require('./features');
1112
const { apiRateLimitOptions } = require('./middlewares/apiRateLimit');
1213

@@ -36,6 +37,12 @@ module.exports = (app) => {
3637
res.json(req.user.toObject());
3738
});
3839

40+
if (isFeatureEnabled('solveHistory')) {
41+
router.use('/solve-history', auth, createSolveHistoryRouter());
42+
} else {
43+
router.use('/solve-history', createSolveHistoryRouter());
44+
}
45+
3946
router.put('/updateUsername', auth, async (req, res) => {
4047
try {
4148
const user = await updateUsername(User, req.user, req.body.username);

server/api/solveHistory.js

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
const express = require('express');
2+
3+
const { isFeatureEnabled } = require('../features');
4+
const { recordSolveHistoryRequest } = require('../metrics');
5+
const { query } = require('../postgres');
6+
const { stableId } = require('../postgres/dualWrite');
7+
8+
const DEFAULT_LIMIT = 50;
9+
const MAX_LIMIT = 100;
10+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
11+
12+
const invalidRequest = (code) => {
13+
const error = new Error(code === 'invalid_cursor' ? 'Invalid cursor' : 'Invalid limit');
14+
error.statusCode = 400;
15+
error.code = code;
16+
return error;
17+
};
18+
19+
const encodeCursor = ({ completedAt, id }) => Buffer.from(JSON.stringify({
20+
completedAt: new Date(completedAt).toISOString(),
21+
id,
22+
})).toString('base64url');
23+
24+
const decodeCursor = (value) => {
25+
if (!value) {
26+
return null;
27+
}
28+
if (typeof value !== 'string' || value.length > 512) {
29+
throw invalidRequest('invalid_cursor');
30+
}
31+
32+
try {
33+
const parsed = JSON.parse(Buffer.from(value, 'base64url').toString('utf8'));
34+
const completedAt = new Date(parsed.completedAt);
35+
if (!parsed || typeof parsed.id !== 'string' || !UUID.test(parsed.id)
36+
|| Number.isNaN(completedAt.getTime())) {
37+
throw new Error('invalid');
38+
}
39+
return { completedAt, id: parsed.id };
40+
} catch {
41+
throw invalidRequest('invalid_cursor');
42+
}
43+
};
44+
45+
const parseLimit = (value) => {
46+
if (value === undefined) {
47+
return DEFAULT_LIMIT;
48+
}
49+
if (typeof value !== 'string' || !/^\d+$/.test(value)) {
50+
throw invalidRequest('invalid_limit');
51+
}
52+
const limit = Number(value);
53+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
54+
throw invalidRequest('invalid_limit');
55+
}
56+
return limit;
57+
};
58+
59+
const asScramble = (scrambles) => {
60+
if (typeof scrambles !== 'string') {
61+
return scrambles;
62+
}
63+
try {
64+
return JSON.parse(scrambles);
65+
} catch {
66+
return scrambles;
67+
}
68+
};
69+
70+
const historyQuery = `
71+
SELECT
72+
s.id AS solve_id,
73+
s.time_ms,
74+
s.dnf,
75+
s.plus_two_penalty,
76+
s.inspection_penalty,
77+
s.auf_penalty,
78+
s.source_created_at AS completed_at,
79+
s.source_updated_at AS updated_at,
80+
a.scrambles,
81+
r.id AS room_id,
82+
rs.id AS race_session_id,
83+
rs.cube_event AS race_session_event
84+
FROM app.solves s
85+
JOIN app.attempts a ON a.id = s.attempt_id AND a.room_id = s.room_id
86+
JOIN app.race_sessions rs ON rs.id = a.race_session_id AND rs.room_id = a.room_id
87+
JOIN app.rooms r ON r.id = s.room_id
88+
JOIN app.room_participants rp
89+
ON rp.room_id = r.id AND rp.user_id = $1 AND rp.banned = false
90+
WHERE s.user_id = $1
91+
AND ($2::timestamptz IS NULL OR (s.source_created_at, s.id) < ($2, $3::uuid))
92+
ORDER BY s.source_created_at DESC, s.id DESC
93+
LIMIT $4
94+
`;
95+
96+
const solveResponse = (row) => ({
97+
id: row.solve_id,
98+
event: row.race_session_event,
99+
timeMs: row.time_ms,
100+
penalties: {
101+
dnf: !!row.dnf,
102+
plus2: !!row.plus_two_penalty,
103+
inspection: !!row.inspection_penalty,
104+
auf: !!row.auf_penalty,
105+
},
106+
scramble: asScramble(row.scrambles),
107+
room: { id: row.room_id },
108+
raceSession: { id: row.race_session_id, event: row.race_session_event },
109+
completedAt: new Date(row.completed_at).toISOString(),
110+
updatedAt: new Date(row.updated_at).toISOString(),
111+
});
112+
113+
const createSolveHistoryRouter = ({
114+
enabled = isFeatureEnabled('solveHistory'),
115+
postgresQuery = query,
116+
recordMetric = recordSolveHistoryRequest,
117+
now = () => Date.now(),
118+
} = {}) => {
119+
const router = express.Router();
120+
121+
router.get('/', async (req, res) => {
122+
const startedAt = now();
123+
let outcome = 'error';
124+
let count = 0;
125+
try {
126+
if (!enabled) {
127+
outcome = 'feature_disabled';
128+
return res.status(404).json({
129+
code: 'feature_disabled',
130+
message: 'This feature is not available',
131+
});
132+
}
133+
134+
const limit = parseLimit(req.query.limit);
135+
const cursor = decodeCursor(req.query.cursor);
136+
const numericId = Number(req.user && req.user.id);
137+
if (!Number.isSafeInteger(numericId) || numericId < 1) {
138+
outcome = 'unauthorized';
139+
return res.status(403).json({ code: 'unauthorized', message: 'Unauthorized' });
140+
}
141+
142+
const result = await postgresQuery(historyQuery, [
143+
stableId('user', numericId),
144+
cursor && cursor.completedAt,
145+
cursor && cursor.id,
146+
limit + 1,
147+
]);
148+
if (!result) {
149+
outcome = 'postgres_unavailable';
150+
return res.status(503).json({
151+
code: 'postgres_unavailable',
152+
message: 'Solve history is temporarily unavailable',
153+
});
154+
}
155+
156+
const rows = result.rows || [];
157+
const hasNextPage = rows.length > limit;
158+
const page = rows.slice(0, limit);
159+
count = page.length;
160+
outcome = 'success';
161+
return res.json({
162+
solves: page.map(solveResponse),
163+
nextCursor: hasNextPage ? encodeCursor({
164+
completedAt: page[page.length - 1].completed_at,
165+
id: page[page.length - 1].solve_id,
166+
}) : null,
167+
});
168+
} catch (err) {
169+
if (err.statusCode === 400) {
170+
outcome = err.code;
171+
return res.status(400).json({ code: err.code, message: err.message });
172+
}
173+
outcome = 'postgres_unavailable';
174+
return res.status(503).json({
175+
code: 'postgres_unavailable',
176+
message: 'Solve history is temporarily unavailable',
177+
});
178+
} finally {
179+
Promise.resolve(recordMetric({
180+
outcome,
181+
count,
182+
latencyMs: Math.max(0, now() - startedAt),
183+
})).catch(() => {});
184+
}
185+
});
186+
187+
return router;
188+
};
189+
190+
module.exports = {
191+
createSolveHistoryRouter,
192+
decodeCursor,
193+
encodeCursor,
194+
parseLimit,
195+
};

server/api/solveHistory.test.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/** @jest-environment node */
2+
/* eslint-env jest */
3+
4+
jest.mock('../metrics', () => ({ recordSolveHistoryRequest: jest.fn() }));
5+
jest.mock('../postgres', () => ({ query: jest.fn() }));
6+
jest.mock('../postgres/dualWrite', () => ({ stableId: (kind, id) => `${kind}:${id}` }));
7+
8+
const { recordSolveHistoryRequest } = require('../metrics');
9+
const { createSolveHistoryRouter, decodeCursor } = require('./solveHistory');
10+
11+
const response = () => {
12+
const res = { json: jest.fn(), status: jest.fn() };
13+
res.status.mockReturnValue(res);
14+
return res;
15+
};
16+
17+
const handlerFor = (options) => createSolveHistoryRouter(options).stack[0].route.stack[0].handle;
18+
19+
describe('solve history API', () => {
20+
beforeEach(() => jest.clearAllMocks());
21+
22+
it('keeps production exposure explicitly disabled', async () => {
23+
const res = response();
24+
await handlerFor({ enabled: false })({ query: {}, user: { id: 123 } }, res);
25+
26+
expect(res.status).toHaveBeenCalledWith(404);
27+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'feature_disabled' }));
28+
});
29+
30+
it('returns only session-linked authorized PostgreSQL history with stable cursors', async () => {
31+
const postgresQuery = jest.fn().mockResolvedValue({ rows: [{
32+
solve_id: '4d8ce8c4-8910-47e9-b39f-79e1280c1e3a',
33+
time_ms: 12345,
34+
dnf: false,
35+
plus_two_penalty: true,
36+
inspection_penalty: false,
37+
auf_penalty: true,
38+
scrambles: ['R U'],
39+
room_id: 'ea1486ca-bce6-4c37-a4c9-1d32002c1e4a',
40+
race_session_id: '121ac5d8-19df-463a-af08-8b7b3c2c94a3',
41+
race_session_event: '333',
42+
completed_at: new Date('2026-07-14T10:00:00.000Z'),
43+
updated_at: new Date('2026-07-14T10:01:00.000Z'),
44+
}] });
45+
const res = response();
46+
47+
await handlerFor({ enabled: true, postgresQuery })({ query: {}, user: { id: 123 } }, res);
48+
49+
expect(postgresQuery.mock.calls[0][0]).toContain('rp.banned = false');
50+
expect(postgresQuery.mock.calls[0][0]).toContain('a.race_session_id');
51+
expect(postgresQuery.mock.calls[0][0]).not.toMatch(/email|access_code|password/i);
52+
expect(res.json).toHaveBeenCalledWith({
53+
solves: [expect.objectContaining({
54+
event: '333', timeMs: 12345, scramble: ['R U'],
55+
penalties: { dnf: false, plus2: true, inspection: false, auf: true },
56+
room: { id: 'ea1486ca-bce6-4c37-a4c9-1d32002c1e4a' },
57+
raceSession: { id: '121ac5d8-19df-463a-af08-8b7b3c2c94a3', event: '333' },
58+
})],
59+
nextCursor: null,
60+
});
61+
expect(recordSolveHistoryRequest).toHaveBeenCalledWith(expect.objectContaining({
62+
outcome: 'success', count: 1,
63+
}));
64+
});
65+
66+
it('returns 503 when PostgreSQL is unavailable and rejects malformed pagination', async () => {
67+
const unavailable = response();
68+
await handlerFor({ enabled: true, postgresQuery: jest.fn().mockResolvedValue(null) })(
69+
{ query: {}, user: { id: 123 } }, unavailable,
70+
);
71+
expect(unavailable.status).toHaveBeenCalledWith(503);
72+
73+
const invalid = response();
74+
await handlerFor({ enabled: true })({ query: { cursor: 'not-a-cursor' }, user: { id: 123 } }, invalid);
75+
expect(invalid.status).toHaveBeenCalledWith(400);
76+
expect(invalid.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'invalid_cursor' }));
77+
expect(() => decodeCursor('not-a-cursor')).toThrow('Invalid cursor');
78+
});
79+
});

server/features.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const FEATURES = Object.freeze({
22
friends: process.env.NODE_ENV !== 'production',
3+
solveHistory: ['development', 'dev'].includes(process.env.NODE_ENV),
34
});
45

56
const isFeatureEnabled = (feature) => FEATURES[feature] === true;

server/features.test.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,11 @@ describe('feature flags', () => {
2121
it('keeps friends available to local development', () => {
2222
expect(loadFeatures('development').isFeatureEnabled('friends')).toBe(true);
2323
});
24+
25+
it('keeps solve history development-only', () => {
26+
expect(loadFeatures('development').isFeatureEnabled('solveHistory')).toBe(true);
27+
expect(loadFeatures('dev').isFeatureEnabled('solveHistory')).toBe(true);
28+
expect(loadFeatures('production').isFeatureEnabled('solveHistory')).toBe(false);
29+
expect(loadFeatures('prod').isFeatureEnabled('solveHistory')).toBe(false);
30+
});
2431
});

0 commit comments

Comments
 (0)