From ce491597beae998d6e990240594321e3bcad4a1e Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Thu, 13 Aug 2026 00:00:51 -0500 Subject: [PATCH 1/2] fix: replicate story IDs so peers joining mid-session can key results --- src/__tests__/protocol.test.js | 108 ++++++++++++++++++++++++++++++++- src/pages/room.js | 3 + src/protocol.js | 26 ++++++-- src/state.js | 4 +- 4 files changed, 133 insertions(+), 8 deletions(-) diff --git a/src/__tests__/protocol.test.js b/src/__tests__/protocol.test.js index 7444cd0..8882597 100644 --- a/src/__tests__/protocol.test.js +++ b/src/__tests__/protocol.test.js @@ -486,9 +486,11 @@ describe('protocol — stories list', () => { expect(s.currentIndex).toBe(-1); }); - it('loadStories broadcasts stories-load event', () => { + it('loadStories broadcasts stories-load event with the IDs', () => { proto.loadStories('X\nY'); - expect(mockRoom.getSend('stories-load')).toHaveBeenCalledWith({ stories: ['X', 'Y'] }); + const [[payload]] = mockRoom.getSend('stories-load').mock.calls; + expect(payload.stories).toEqual(['X', 'Y']); + expect(payload.storyIds).toEqual(latest(updates).storyIds); }); it('incoming stories-load updates list', () => { @@ -498,6 +500,108 @@ describe('protocol — stories list', () => { }); }); +// ── Story IDs ───────────────────────────────────────────────────────────────── +// +// Results are keyed by story ID, so an ID that goes missing or slips out of +// alignment silently attaches a round's outcome to the wrong story — or throws +// on a list that is `undefined`. + +describe('protocol — story IDs', () => { + let proto, updates; + + beforeEach(async () => { + vi.useFakeTimers(); + vi.resetModules(); + ({ proto, updates } = await makeProtocol()); + vi.advanceTimersByTime(1); + }); + + afterEach(() => { proto.destroy(); vi.useRealTimers(); }); + + it('keeps one ID per story', () => { + proto.loadStories('A\nB\nC'); + const s = latest(updates); + expect(s.storyIds).toHaveLength(3); + expect(new Set(s.storyIds).size).toBe(3); + }); + + it('derives storyId from the story being voted on', () => { + proto.loadStories('A\nB'); + const { storyIds } = latest(updates); + proto.startVoting('B'); + expect(latest(updates).storyId).toBe(storyIds[1]); + }); + + it('follows the story across nextStory, which never set storyId', () => { + proto.loadStories('A\nB'); + const { storyIds } = latest(updates); + proto.startVoting('A'); + proto.nextStory(); + expect(latest(updates).storyId).toBe(storyIds[1]); + }); + + it('reports no storyId while no story is selected', () => { + expect(latest(updates).storyId).toBeNull(); + }); + + it('adopts the IDs carried by a stories-load', () => { + mockRoom.triggerAction('stories-load', { + stories: ['Remote A', 'Remote B'], + storyIds: ['id-a', 'id-b'], + }); + expect(latest(updates).storyIds).toEqual(['id-a', 'id-b']); + }); + + it('survives a state-sync — the regression: storyIds arrived undefined', () => { + mockRoom.triggerAction('state-sync', { + state: { + roomId: 'ROOM-TEST', + stories: ['Auth flow', 'Billing'], + storyIds: ['id-auth', 'id-billing'], + currentIndex: 1, + storyTitle: 'Billing', + phase: 'voting', + votes: {}, + participants: { 'u9': mkPeer('u9', 'Zoe') }, + roundId: 'r1', + autoReveal: true, + }, + }); + const s = latest(updates); + expect(s.storyIds).toEqual(['id-auth', 'id-billing']); + expect(s.storyId).toBe('id-billing'); + // Every path that indexes storyIds used to throw on `undefined`. + expect(() => proto.startVoting('Auth flow')).not.toThrow(); + expect(latest(updates).storyId).toBe('id-auth'); + }); + + it('fills in IDs a peer omitted rather than leaving the arrays ragged', () => { + mockRoom.triggerAction('stories-load', { stories: ['A', 'B', 'C'] }); + const s = latest(updates); + expect(s.storyIds).toHaveLength(3); + expect(s.storyIds.every(id => typeof id === 'string' && id.length > 0)).toBe(true); + }); + + it('replaces an unusable ID in place, keeping later stories aligned', () => { + mockRoom.triggerAction('stories-load', { + stories: ['A', 'B', 'C'], + storyIds: ['id-a', 42, 'id-c'], + }); + const s = latest(updates); + expect(s.storyIds).toHaveLength(3); + expect(s.storyIds[0]).toBe('id-a'); + expect(s.storyIds[2]).toBe('id-c'); + }); + + it('truncates IDs a peer sent in excess of the stories', () => { + mockRoom.triggerAction('stories-load', { + stories: ['A'], + storyIds: ['id-a', 'id-b', 'id-c'], + }); + expect(latest(updates).storyIds).toEqual(['id-a']); + }); +}); + // ── Payload validation (untrusted peers) ──────────────────────────────────────── describe('protocol — payload validation', () => { diff --git a/src/pages/room.js b/src/pages/room.js index af0b442..7f7ef1f 100644 --- a/src/pages/room.js +++ b/src/pages/room.js @@ -290,6 +290,9 @@ function loadStoriesFromStorage(roomId) { } function saveResultToStorage(roomId, storyId, storyTitle, votes) { + // Results are looked up by story ID. A round with no story behind it — voting + // started before any list was loaded — would write an entry nothing can read. + if (!storyId) return; const key = `quorum-results-${roomId}`; const existing = JSON.parse(localStorage.getItem(key) || '[]'); const stats = computeStats(votes); diff --git a/src/protocol.js b/src/protocol.js index 78433fe..f0ba801 100644 --- a/src/protocol.js +++ b/src/protocol.js @@ -24,6 +24,16 @@ const cleanStories = (arr) => : []; const isValidId = (id) => typeof id === 'string' && id.length > 0 && id.length <= MAX_ID_LEN; + +// Story IDs index `stories` positionally, so the two arrays must stay the same +// length whatever a peer sends. Anything unusable is replaced, never dropped — +// dropping would shift every later ID onto the wrong story. +const cleanStoryIds = (arr, count) => { + const src = Array.isArray(arr) ? arr : []; + return Array.from({ length: count }, (_, i) => + isValidId(src[i]) ? src[i] : crypto.randomUUID()); +}; + const isCard = (c) => c == null || DECK.includes(c); /** @@ -68,6 +78,9 @@ export function createProtocol(roomId, userId, userName, onUpdate, onCountdown) ...state, participants: { ...state.participants }, votes: { ...state.votes }, + // Derived, never stored: whichever story currentIndex points at. Kept as + // a field only because consumers read it to key saved results. + storyId: state.storyIds[state.currentIndex] ?? null, }; } @@ -174,9 +187,11 @@ export function createProtocol(roomId, userId, userName, onUpdate, onCountdown) for (const id of Object.keys(participants)) { votes[id] = isCard(incoming.votes[id]) ? incoming.votes[id] ?? null : null; } + const stories = cleanStories(incoming.stories); state = { roomId, - stories: cleanStories(incoming.stories), + stories, + storyIds: cleanStoryIds(incoming.storyIds, stories.length), currentIndex: Number.isInteger(incoming.currentIndex) ? incoming.currentIndex : -1, storyTitle: typeof incoming.storyTitle === 'string' ? incoming.storyTitle.slice(0, MAX_TITLE_LEN) : '', phase: VALID_PHASES.includes(incoming.phase) ? incoming.phase : 'waiting', @@ -251,6 +266,9 @@ export function createProtocol(roomId, userId, userName, onUpdate, onCountdown) const stories = isObj(data) ? data.stories : null; if (!Array.isArray(stories)) return; state.stories = cleanStories(stories); + // The IDs travel with the stories they belong to. Rebuilding them locally + // would leave every peer keying results by a different ID. + state.storyIds = cleanStoryIds(data.storyIds, state.stories.length); state.currentIndex = -1; emit(); }); @@ -324,9 +342,7 @@ export function createProtocol(roomId, userId, userName, onUpdate, onCountdown) state.storyTitle = title; state.phase = 'voting'; state.roundId = newRoundId; - const idx = state.stories.indexOf(title); - state.currentIndex = idx; - state.storyId = idx >= 0 ? state.storyIds[idx] : null; + state.currentIndex = state.stories.indexOf(title); revealPending = false; clearVotes(); sendNewStory({ index: state.currentIndex, title, roundId: newRoundId, phase: 'voting' }); @@ -418,7 +434,7 @@ export function createProtocol(roomId, userId, userName, onUpdate, onCountdown) } else { state.currentIndex = -1; } - sendStoriesLoad({ stories }); + sendStoriesLoad({ stories, storyIds: newIds }); emit(); }, diff --git a/src/state.js b/src/state.js index 779c4f8..c37f473 100644 --- a/src/state.js +++ b/src/state.js @@ -5,10 +5,12 @@ export function createInitialState(roomId) { return { roomId, stories: [], + // Parallel to `stories`, same length: storyIds[i] identifies stories[i]. + // `storyId` is not stored — it is derived from currentIndex on read, so it + // can never drift out of step with the story actually being voted on. storyIds: [], currentIndex: -1, storyTitle: '', - storyId: null, phase: 'waiting', votes: {}, participants: {}, From 09b3b62299c0a1681379df64d5d5d1495657ab57 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Thu, 13 Aug 2026 08:29:18 -0500 Subject: [PATCH 2/2] feat: bump version to 0.1.7 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6aeb9f2..dd0bc43 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@univerlab/quorum", - "version": "0.1.6", + "version": "0.1.7", "description": "Browser-native planning poker — P2P, no accounts, no server", "license": "MIT", "type": "module",