diff --git a/crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts b/crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts index d57e374..c262cdb 100644 --- a/crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts +++ b/crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts @@ -50,8 +50,17 @@ interface MutableNomination extends Nomination { messageIds: Set; } +/** + * Event nonces are only unique within one author's history: the backend + * validates them per authenticated author, so two authors may legitimately + * (or deliberately) publish events with the same nonce. Every place that + * deduplicates or keys events therefore scopes the nonce by author. + */ +export const eventKeyOf = (event: Pick): string => + `${event.author_id}\u0000${event.id}`; + const compareEvents = (a: CallToPlayViewEvent, b: CallToPlayViewEvent): number => - a.at - b.at || a.id.localeCompare(b.id); + a.at - b.at || eventKeyOf(a).localeCompare(eventKeyOf(b)); type CreatePayload = Extract['Create']; type RespondPayload = Extract['Respond']; @@ -135,7 +144,7 @@ export const reduceCallToPlayEvents = ( const groupEvents = ( input: ReadonlyArray, ): Map => { - const unique = new Map(input.map(event => [event.id, event])); + const unique = new Map(input.map(event => [eventKeyOf(event), event])); const byCall = new Map(); for (const event of unique.values()) { const events = byCall.get(event.call_id) ?? []; @@ -218,10 +227,11 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayViewEvent): } const message = messagePayload(action); - if (message && !nomination.messageIds.has(event.id)) { - nomination.messageIds.add(event.id); + const messageKey = eventKeyOf(event); + if (message && !nomination.messageIds.has(messageKey)) { + nomination.messageIds.add(messageKey); nomination.messages.push({ - id: event.id, + id: messageKey, fromId: event.author_id, from: event.author_name, text: message.text, diff --git a/crates/lanspread-tauri-deno-ts/tests/callToPlay.test.ts b/crates/lanspread-tauri-deno-ts/tests/callToPlay.test.ts index 7c0a98c..a3bfe4d 100644 --- a/crates/lanspread-tauri-deno-ts/tests/callToPlay.test.ts +++ b/crates/lanspread-tauri-deno-ts/tests/callToPlay.test.ts @@ -54,6 +54,27 @@ const create = ( }, }); +Deno.test('events from different authors never collapse on a shared nonce', () => { + const respond = event('create', 'Bob', { Respond: { ready_at: null } }, NOW + 1); + const [nomination] = reduceCallToPlayEvents([create(), respond], NOW + 2); + assert(nomination, 'the creator event must survive a colliding nonce from another author'); + assertEquals(nomination.creator, 'Alice', 'creator'); + assertEquals(Object.keys(nomination.participants).length, 2, 'both participants applied'); + + const aliceMessage = event('m1', 'Alice', { SendMessage: { text: 'hi' } }, NOW + 3); + const bobMessage = event('m1', 'Bob', { SendMessage: { text: 'yo' } }, NOW + 4); + const [withMessages] = reduceCallToPlayEvents( + [create(), respond, aliceMessage, bobMessage], + NOW + 5, + ); + assert(withMessages, 'call should exist'); + assertEquals(withMessages.messages.length, 2, 'messages with a shared nonce are distinct'); + assert( + withMessages.messages[0].id !== withMessages.messages[1].id, + 'message keys are author scoped', + ); +}); + Deno.test('play-now call starts with its creator ready', () => { const [nomination] = reduceCallToPlayEvents([create()], NOW); assert(nomination, 'call should exist');