diff --git a/packages/web/src/stores/spaceStore.dmAlternatives.test.ts b/packages/web/src/stores/spaceStore.dmAlternatives.test.ts index 073e96a4..e716da08 100644 --- a/packages/web/src/stores/spaceStore.dmAlternatives.test.ts +++ b/packages/web/src/stores/spaceStore.dmAlternatives.test.ts @@ -155,4 +155,61 @@ describe('spaceStore.dmAlternatives', () => { expect(useSpaceStore.getState().dmAlternatives.has('fed-solo')).toBe(false); }); + + it('resolveDmChannelId returns the id itself if it is already a primary dmChannels entry', async () => { + const { resolveDmChannelId } = await import('./spaceStore'); + useSpaceStore.getState().populateFromReady( + '', + [], + [], + [makeDm('home-1', 'fed-aaa')], + null, + 0, + ); + expect(resolveDmChannelId('home-1')).toBe('home-1'); + }); + + it('resolveDmChannelId resolves an alternative id to the primary via federatedId', async () => { + const { resolveDmChannelId } = await import('./spaceStore'); + // Home loads first → home-1 becomes the primary in dmChannels. + useSpaceStore.getState().populateFromReady( + '', + [], + [], + [makeDm('home-1', 'fed-aaa')], + null, + 0, + ); + // Remote ready later → remote-1 recorded in dmAlternatives but deduped out of dmChannels. + useSpaceStore.getState().populateFromReady( + 'https://remote.example', + [], + [], + [makeDm('remote-1', 'fed-aaa')], + null, + 0, + ); + expect(resolveDmChannelId('remote-1')).toBe('home-1'); + }); + + it('resolveDmChannelId returns null if the id is unknown everywhere', async () => { + const { resolveDmChannelId } = await import('./spaceStore'); + expect(resolveDmChannelId('nonexistent')).toBeNull(); + }); + + it('resolveDmChannelId returns null if the alternative points to a federatedId no longer in dmChannels', async () => { + const { resolveDmChannelId } = await import('./spaceStore'); + useSpaceStore.getState().populateFromReady( + 'https://remote.example', + [], + [], + [makeDm('remote-1', 'fed-aaa')], + null, + 0, + ); + // Simulate the dmChannels entry getting removed without clearing dmAlternatives — + // resolveDmChannelId should gracefully return null rather than a stale pointer. + useSpaceStore.setState({ dmChannels: [] }); + expect(resolveDmChannelId('remote-1')).toBeNull(); + }); }); diff --git a/packages/web/src/stores/spaceStore.ts b/packages/web/src/stores/spaceStore.ts index 3987d0a6..c64023b3 100644 --- a/packages/web/src/stores/spaceStore.ts +++ b/packages/web/src/stores/spaceStore.ts @@ -924,6 +924,33 @@ export function getChannelOrigin(channelId: string): string { return useSpaceStore.getState().channelOriginMap.get(channelId) ?? ''; } +/** + * Resolves a raw DM channel ID to its primary `dmChannels` entry ID. + * + * - If `rawId` is already a primary entry: returns `rawId` unchanged. + * - If `rawId` is recorded in `dmAlternatives` as an alternate-origin local ID + * for a DM whose primary is present in `dmChannels`: returns the primary's ID. + * - Otherwise: returns `null` (unknown ID — caller should no-op). + * + * Used by: + * - `dm_message_created` WS handler to route messages arriving from alternate + * origins to the primary entry (§3.11 of the failover spec). + * - Future DM WS handlers that need to dedup alternate-origin deliveries. + */ +export function resolveDmChannelId(rawId: string): string | null { + const { dmChannels, dmAlternatives } = useSpaceStore.getState(); + if (dmChannels.some(dm => dm.id === rawId)) return rawId; + + for (const [federatedId, byOrigin] of dmAlternatives) { + for (const localId of byOrigin.values()) { + if (localId !== rawId) continue; + const primary = dmChannels.find(dm => dm.federatedId === federatedId); + return primary ? primary.id : null; + } + } + return null; +} + // ─── API client resolution ──────────────────────────────────────────────────── // The actual resolver is registered by instanceStore on import, avoiding a // circular dependency (instanceStore → useWebSocket → chatStore → spaceStore).