From 678790b88b60a0cec567a2bf9fc7e1cb39f8d43b Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:06:46 +0200 Subject: [PATCH] feat(federation): resolveDmChannelId for alternate-origin DM ids Resolves any raw DM channel ID (primary or alternate-origin local ID) to its primary dmChannels entry via dmAlternatives federatedId lookup. Returns null for unknown IDs. Used by the dm_message_created handler in a later commit to prevent phantom sidebar entries from alternate- origin deliveries (closes a pre-existing group-DM bug and supports post-failover routing). --- .../stores/spaceStore.dmAlternatives.test.ts | 57 +++++++++++++++++++ packages/web/src/stores/spaceStore.ts | 27 +++++++++ 2 files changed, 84 insertions(+) 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).