diff --git a/server/models/room.js b/server/models/room.js index 275f102..afdd2d6 100644 --- a/server/models/room.js +++ b/server/models/room.js @@ -527,7 +527,9 @@ const collectPostgresChanges = (room) => { return { attempts, participantUserIds: [...participantUserIds], - replaceAttempts: !room.isNew && room.isModified('event'), + // An event switch starts a new attempt stream. The migration writer must + // mirror it without deleting attempts from the previous event. + syncAllAttempts: !room.isNew && room.isModified('event'), syncAllParticipants: room.isNew, syncRoomOwners: room.isNew || room.isModified('owner') || room.isModified('admin'), }; diff --git a/server/models/room.test.js b/server/models/room.test.js index 0505c69..bf42ab0 100644 --- a/server/models/room.test.js +++ b/server/models/room.test.js @@ -407,13 +407,13 @@ describe('room security helpers', () => { syncAllResults: false, }], participantUserIds: ['1234'], - replaceAttempts: false, + syncAllAttempts: false, syncAllParticipants: false, syncRoomOwners: false, }); }); - it('replaces PostgreSQL attempts when the room event resets them', () => { + it('syncs the new PostgreSQL attempt stream when the room event resets it', () => { const room = RoomModel.hydrate({ _id: '507f1f77bcf86cd799439011', name: 'Practice room', @@ -439,7 +439,7 @@ describe('room security helpers', () => { syncAllResults: true, }], participantUserIds: [], - replaceAttempts: true, + syncAllAttempts: true, syncAllParticipants: false, syncRoomOwners: false, }); diff --git a/server/postgres/dualWrite.js b/server/postgres/dualWrite.js index 32db886..42a0cf7 100644 --- a/server/postgres/dualWrite.js +++ b/server/postgres/dualWrite.js @@ -537,7 +537,7 @@ const syncAttemptResults = async (client, roomState, attemptState, attempt, user const writeRoomChanges = async (client, room, changes) => { const resultUserIds = new Set(); - if (changes.replaceAttempts) { + if (changes.syncAllAttempts) { (room.attempts || []).forEach((attempt) => { resultEntries(attempt.results).forEach(([userId]) => resultUserIds.add(userId)); }); @@ -563,8 +563,7 @@ const writeRoomChanges = async (client, room, changes) => { return null; } - if (changes.replaceAttempts) { - await client.query('DELETE FROM app.attempts WHERE room_id = $1', [roomState.roomId]); + if (changes.syncAllAttempts) { for (const [attemptIndex, attempt] of (room.attempts || []).entries()) { const attemptState = await upsertAttempt(client, room, roomState, attempt, attemptIndex); await syncAttemptResults( diff --git a/server/postgres/dualWrite.test.js b/server/postgres/dualWrite.test.js index 5a4ea80..6198edd 100644 --- a/server/postgres/dualWrite.test.js +++ b/server/postgres/dualWrite.test.js @@ -175,6 +175,48 @@ describe('PostgreSQL dual writer', () => { .toContain(12001); }); + it('retains prior-event rows when an event switch starts a new attempt stream', async () => { + const updatedAt = new Date('2026-07-10T20:00:00.000Z'); + const room = { + _id: '507f1f77bcf86cd799439011', + name: 'Practice room', + event: '222', + accessCode: 'room-access-code', + type: 'normal', + owner: user, + admin: user, + users: [user], + attempts: [{ + id: 0, + scrambles: ['R U2 R\''], + results: new Map([['1234', { + time: 8000, + penalties: {}, + createdAt: updatedAt, + updatedAt, + }]]), + createdAt: updatedAt, + updatedAt, + }], + createdAt: updatedAt, + updatedAt, + }; + + await mirrorRoomChanges(room, { + attempts: [], + participantUserIds: [], + syncAllAttempts: true, + syncAllParticipants: false, + syncRoomOwners: true, + }); + + const statements = client.query.mock.calls.map(([sql]) => sql); + expect(statements.some((sql) => sql.includes('DELETE FROM app.attempts'))).toBe(false); + expect(statements.some((sql) => sql.includes('DELETE FROM app.solves'))).toBe(false); + expect(statements.filter((sql) => sql.includes('INSERT INTO app.attempts'))).toHaveLength(1); + expect(statements.filter((sql) => sql.includes('INSERT INTO app.solves'))).toHaveLength(1); + }); + it('mirrors sanitized metric events and soft-deletes rooms', async () => { const occurredAt = new Date('2026-07-09T20:00:00.000Z'); await mirrorMetricEvent({