From 088fd40834430d2d18f2f072e3494cfeff44bdc9 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 23 Apr 2026 00:58:37 +0200 Subject: [PATCH 1/9] feat(federation): record DM origin alternatives in spaceStore Every DM arriving in a ready payload with a federatedId now gets its (origin, localChannelId) pair recorded in dmAlternatives, regardless of whether the dedup pass kept this copy in dmChannels. Enables client-side DM origin failover: when the primary origin drops, we can look up an alternate origin's local channel ID for the same federated DM. Prep for #10 (DM origin failover on disconnect). --- .../stores/spaceStore.dmAlternatives.test.ts | 116 ++++++++++++++++++ packages/web/src/stores/spaceStore.ts | 21 ++++ 2 files changed, 137 insertions(+) create mode 100644 packages/web/src/stores/spaceStore.dmAlternatives.test.ts diff --git a/packages/web/src/stores/spaceStore.dmAlternatives.test.ts b/packages/web/src/stores/spaceStore.dmAlternatives.test.ts new file mode 100644 index 00000000..d08b7e78 --- /dev/null +++ b/packages/web/src/stores/spaceStore.dmAlternatives.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Stub AudioManager to avoid AudioWorkletNode reference error in jsdom +// (spaceStore → authStore → voiceStore → AudioManager → AudioWorkletNode) +vi.mock('../audio/AudioManager', () => ({ + AudioManager: { + getInstance: vi.fn().mockReturnValue({ + setOutputDevice: vi.fn(), + setVolume: vi.fn(), + }), + }, +})); + +// Stub instanceStore to avoid initialization ordering issues +// (spaceStore → authStore → socialStore → instanceStore → setApiForOriginResolver) +vi.mock('./instanceStore', () => ({ + useInstanceStore: Object.assign( + (selector: (s: unknown) => unknown) => selector({ instances: [], _autoConnectDone: true }), + { + getState: () => ({ instances: [], _autoConnectDone: true }), + setState: vi.fn(), + subscribe: vi.fn(), + } + ), +})); + +// Stub authStore to avoid localStorage access during module init +vi.mock('./authStore', () => ({ + useAuthStore: Object.assign( + (selector: (s: unknown) => unknown) => selector({ user: null, token: null }), + { + getState: () => ({ user: null, token: null }), + setState: vi.fn(), + subscribe: vi.fn(), + } + ), +})); + +import { useSpaceStore } from './spaceStore'; +import type { DmChannel } from '@backspace/shared'; + +function makeDm(id: string, federatedId: string | null, extras: Partial = {}): DmChannel { + return { + id, + federatedId, + createdAt: 1000, + members: [], + ...extras, + }; +} + +beforeEach(() => { + useSpaceStore.getState().reset(); +}); + +describe('spaceStore.dmAlternatives', () => { + it('records origin + local channel id for each DM with federatedId on populateFromReady', () => { + const dmsFromHome: DmChannel[] = [ + makeDm('home-1', 'fed-aaa'), + makeDm('home-2', 'fed-bbb'), + makeDm('home-3', null), // no federatedId — not indexed + ]; + useSpaceStore.getState().populateFromReady('', [], [], dmsFromHome, null, 0); + + const alts = useSpaceStore.getState().dmAlternatives; + expect(alts.get('fed-aaa')?.get('')).toBe('home-1'); + expect(alts.get('fed-bbb')?.get('')).toBe('home-2'); + expect(alts.has(null as any)).toBe(false); + }); + + it('accumulates entries across multiple origins for the same federatedId', () => { + useSpaceStore.getState().populateFromReady( + '', + [], + [], + [makeDm('home-1', 'fed-aaa')], + null, + 0, + ); + useSpaceStore.getState().populateFromReady( + 'https://remote.example', + [], + [], + [makeDm('remote-1', 'fed-aaa')], + null, + 0, + ); + + const byOrigin = useSpaceStore.getState().dmAlternatives.get('fed-aaa'); + expect(byOrigin?.get('')).toBe('home-1'); + expect(byOrigin?.get('https://remote.example')).toBe('remote-1'); + }); + + it('updates the local id if the same origin reports a different id for a federatedId', () => { + useSpaceStore.getState().populateFromReady( + 'https://remote.example', + [], + [], + [makeDm('remote-old', 'fed-aaa')], + null, + 0, + ); + useSpaceStore.getState().populateFromReady( + 'https://remote.example', + [], + [], + [makeDm('remote-new', 'fed-aaa')], + null, + 0, + ); + + const byOrigin = useSpaceStore.getState().dmAlternatives.get('fed-aaa'); + expect(byOrigin?.get('https://remote.example')).toBe('remote-new'); + expect(byOrigin?.size).toBe(1); + }); +}); diff --git a/packages/web/src/stores/spaceStore.ts b/packages/web/src/stores/spaceStore.ts index cafec7a1..e01d1d93 100644 --- a/packages/web/src/stores/spaceStore.ts +++ b/packages/web/src/stores/spaceStore.ts @@ -41,6 +41,8 @@ interface SpaceState { channelOriginMap: Map; // channelId → instance origin ('' = home) voiceChannelIds: Set; // channelIds that are voice channels (excluded from unread) categoryOriginMap: Map; // categoryId → instance origin ('' = home) + /** federatedId → (origin → localChannelId). Every DM from every origin's ready payload is recorded here regardless of dedup outcome, so failover can re-point to an alternate origin's local channel ID. */ + dmAlternatives: Map>; loadingSpaceId: string | null; // non-null while loadSpaceDetail is fetching _layoutUpdatedAt: number; setSpaces: (spaces: TaggedSpace[]) => void; @@ -132,6 +134,7 @@ export const useSpaceStore = create((set, get) => ({ channelOriginMap: new Map(), voiceChannelIds: new Set(), categoryOriginMap: new Map(), + dmAlternatives: new Map(), loadingSpaceId: null, _layoutUpdatedAt: 0, @@ -154,6 +157,7 @@ export const useSpaceStore = create((set, get) => ({ channelOriginMap: new Map(), voiceChannelIds: new Set(), categoryOriginMap: new Map(), + dmAlternatives: new Map(), loadingSpaceId: null, _layoutUpdatedAt: 0, }); @@ -584,6 +588,10 @@ export const useSpaceStore = create((set, get) => ({ const channelOriginMap = new Map(get().channelOriginMap); const voiceChannelIds = new Set(get().voiceChannelIds); const categoryOriginMap = new Map(get().categoryOriginMap); + const dmAlternatives = new Map>(); + for (const [fid, byOrigin] of get().dmAlternatives) { + dmAlternatives.set(fid, new Map(byOrigin)); + } // If home, clear home-origin entries first to avoid stale data if (isHome) { @@ -688,6 +696,18 @@ export const useSpaceStore = create((set, get) => ({ } } + // Record every DM's (origin → localChannelId) for failover lookup, + // regardless of whether the dedup pass kept this copy in dmChannels. + for (const dm of incomingDms) { + if (!dm.federatedId) continue; + let byOrigin = dmAlternatives.get(dm.federatedId); + if (!byOrigin) { + byOrigin = new Map(); + dmAlternatives.set(dm.federatedId, byOrigin); + } + byOrigin.set(origin, dm.id); + } + // Merge: remove DMs belonging to this origin from existing state, then append incoming const existingDmsFromOtherOrigins = get().dmChannels.filter(dm => { const dmOrigin = get().channelOriginMap.get(dm.id); @@ -711,6 +731,7 @@ export const useSpaceStore = create((set, get) => ({ channelOriginMap, voiceChannelIds, categoryOriginMap, + dmAlternatives, }; // LWW layout merge: accept incoming layout only if its timestamp is >= ours From e7430f1a54d69754fdd591ef064dccce8f48ccb3 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:02:05 +0200 Subject: [PATCH 2/9] feat(federation): prune dmAlternatives on removeInstanceSpaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the given origin from every inner (origin→localId) map; removes the outer federatedId entry when its inner map becomes empty. Keeps the store from accumulating stale origin references across long sessions with connect/disconnect churn. --- .../stores/spaceStore.dmAlternatives.test.ts | 42 +++++++++++++++++++ packages/web/src/stores/spaceStore.ts | 9 ++++ 2 files changed, 51 insertions(+) diff --git a/packages/web/src/stores/spaceStore.dmAlternatives.test.ts b/packages/web/src/stores/spaceStore.dmAlternatives.test.ts index d08b7e78..073e96a4 100644 --- a/packages/web/src/stores/spaceStore.dmAlternatives.test.ts +++ b/packages/web/src/stores/spaceStore.dmAlternatives.test.ts @@ -113,4 +113,46 @@ describe('spaceStore.dmAlternatives', () => { expect(byOrigin?.get('https://remote.example')).toBe('remote-new'); expect(byOrigin?.size).toBe(1); }); + + it('removeInstanceSpaces drops the origin from every inner map', () => { + useSpaceStore.getState().populateFromReady( + '', + [], + [], + [makeDm('home-1', 'fed-aaa'), makeDm('home-2', 'fed-bbb')], + null, + 0, + ); + useSpaceStore.getState().populateFromReady( + 'https://remote.example', + [], + [], + [makeDm('remote-1', 'fed-aaa'), makeDm('remote-2', 'fed-bbb')], + null, + 0, + ); + + useSpaceStore.getState().removeInstanceSpaces('https://remote.example'); + + const alts = useSpaceStore.getState().dmAlternatives; + expect(alts.get('fed-aaa')?.has('https://remote.example')).toBe(false); + expect(alts.get('fed-aaa')?.get('')).toBe('home-1'); + expect(alts.get('fed-bbb')?.has('https://remote.example')).toBe(false); + expect(alts.get('fed-bbb')?.get('')).toBe('home-2'); + }); + + it('removeInstanceSpaces deletes federatedId entry if its inner map becomes empty', () => { + useSpaceStore.getState().populateFromReady( + 'https://remote.example', + [], + [], + [makeDm('remote-only', 'fed-solo')], + null, + 0, + ); + + useSpaceStore.getState().removeInstanceSpaces('https://remote.example'); + + expect(useSpaceStore.getState().dmAlternatives.has('fed-solo')).toBe(false); + }); }); diff --git a/packages/web/src/stores/spaceStore.ts b/packages/web/src/stores/spaceStore.ts index e01d1d93..3987d0a6 100644 --- a/packages/web/src/stores/spaceStore.ts +++ b/packages/web/src/stores/spaceStore.ts @@ -870,6 +870,14 @@ export const useSpaceStore = create((set, get) => ({ } } + // Prune dmAlternatives: drop this origin from every inner map. + const dmAlternatives = new Map>(); + for (const [fid, byOrigin] of state.dmAlternatives) { + const nextInner = new Map(byOrigin); + nextInner.delete(origin); + if (nextInner.size > 0) dmAlternatives.set(fid, nextInner); + } + return { spaces: remainingSpaces, channelToSpaceMap, @@ -877,6 +885,7 @@ export const useSpaceStore = create((set, get) => ({ channelPermissions, channelOriginMap, spacePermissions, + dmAlternatives, currentSpaceId: remainingSpaces.find(s => s.id === state.currentSpaceId) ? state.currentSpaceId : null, From d66932362a3d6e59f506a2bf6ed0a4e088ea1aa8 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:04:42 +0200 Subject: [PATCH 3/9] feat(chat): rekeyChannelState moves channel state from oldId to newId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deletes every channel-keyed entry under oldId (messages, hasMore, typingUsers, readStates, channelAccessTimes, scrollPositions) without seeding newId — subscribers refetch naturally from the new origin. Transfers unreadChannels membership only if oldId was already unread (mirror state, don't over-badge). Updates currentChannelId if it matched oldId. Groundwork for DM origin failover rekey. --- .../web/src/stores/chatStore.rekey.test.ts | 128 ++++++++++++++++++ packages/web/src/stores/chatStore.ts | 39 ++++++ 2 files changed, 167 insertions(+) create mode 100644 packages/web/src/stores/chatStore.rekey.test.ts diff --git a/packages/web/src/stores/chatStore.rekey.test.ts b/packages/web/src/stores/chatStore.rekey.test.ts new file mode 100644 index 00000000..1e61b5ae --- /dev/null +++ b/packages/web/src/stores/chatStore.rekey.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('../hooks/useWebSocket', () => ({ + wsSend: vi.fn(), + wsSendAll: vi.fn(), +})); + +// Stub AudioManager to avoid AudioWorkletNode reference error in jsdom +vi.mock('../audio/AudioManager', () => ({ + AudioManager: { + getInstance: vi.fn().mockReturnValue({ + setOutputDevice: vi.fn(), + setVolume: vi.fn(), + }), + }, +})); + +// Stub instanceStore to avoid initialization ordering issues +vi.mock('./instanceStore', () => ({ + useInstanceStore: Object.assign( + (selector: (s: unknown) => unknown) => selector({ instances: [], _autoConnectDone: true }), + { + getState: () => ({ instances: [], _autoConnectDone: true }), + setState: vi.fn(), + subscribe: vi.fn(), + } + ), +})); + +// Stub authStore to avoid localStorage access during module init +vi.mock('./authStore', () => ({ + useAuthStore: Object.assign( + (selector: (s: unknown) => unknown) => selector({ user: null, token: null }), + { + getState: () => ({ user: null, token: null }), + setState: vi.fn(), + subscribe: vi.fn(), + } + ), +})); + +import { useChatStore } from './chatStore'; +import type { MessageWithUser } from '@backspace/shared'; + +function msg(id: string): MessageWithUser { + return { + id, + channelId: 'c', + userId: 'u', + content: 'hi', + createdAt: 1, + user: { id: 'u', username: 'u', displayName: null, avatar: null, homeInstance: null, homeUserId: null, accentColor: null, banner: null, bio: null, status: 'online', activities: [], createdAt: 1 } as any, + attachments: [], + embeds: [], + reactions: [], + }; +} + +beforeEach(() => { + const s = useChatStore.getState(); + // Reset every map/set/scalar this test touches. + useChatStore.setState({ + messages: new Map(), + typingUsers: new Map(), + hasMore: new Map(), + readStates: new Map(), + unreadChannels: new Set(), + channelAccessTimes: new Map(), + scrollPositions: new Map(), + currentChannelId: null, + }); + void s; +}); + +describe('chatStore.rekeyChannelState', () => { + it('removes all channel-keyed state for oldId', () => { + useChatStore.setState({ + messages: new Map([['A1', [msg('m1')]]]), + typingUsers: new Map([['A1', [{ userId: 'u', username: 'u', timestamp: 1 }]]]), + hasMore: new Map([['A1', true]]), + readStates: new Map([['A1', 'msg-last']]), + channelAccessTimes: new Map([['A1', 123]]), + scrollPositions: new Map([['A1', 'msg-scroll']]), + }); + + useChatStore.getState().rekeyChannelState('A1', 'B1'); + + const s = useChatStore.getState(); + expect(s.messages.has('A1')).toBe(false); + expect(s.typingUsers.has('A1')).toBe(false); + expect(s.hasMore.has('A1')).toBe(false); + expect(s.readStates.has('A1')).toBe(false); + expect(s.channelAccessTimes.has('A1')).toBe(false); + expect(s.scrollPositions.has('A1')).toBe(false); + // newId entries are NOT seeded for messages/hasMore/etc — they refetch naturally. + expect(s.messages.has('B1')).toBe(false); + }); + + it('transfers unreadChannels membership only when oldId was unread', () => { + useChatStore.setState({ unreadChannels: new Set(['A1']) }); + useChatStore.getState().rekeyChannelState('A1', 'B1'); + expect(useChatStore.getState().unreadChannels.has('A1')).toBe(false); + expect(useChatStore.getState().unreadChannels.has('B1')).toBe(true); + }); + + it('does not add newId to unreadChannels if oldId was not unread', () => { + useChatStore.setState({ unreadChannels: new Set(['other']) }); + useChatStore.getState().rekeyChannelState('A1', 'B1'); + expect(useChatStore.getState().unreadChannels.has('B1')).toBe(false); + expect(useChatStore.getState().unreadChannels.has('other')).toBe(true); + }); + + it('updates currentChannelId when it matches oldId', () => { + useChatStore.setState({ currentChannelId: 'A1' }); + useChatStore.getState().rekeyChannelState('A1', 'B1'); + expect(useChatStore.getState().currentChannelId).toBe('B1'); + }); + + it('leaves currentChannelId alone when it does not match oldId', () => { + useChatStore.setState({ currentChannelId: 'other' }); + useChatStore.getState().rekeyChannelState('A1', 'B1'); + expect(useChatStore.getState().currentChannelId).toBe('other'); + }); + + it('no-ops cleanly when oldId is not present anywhere', () => { + expect(() => useChatStore.getState().rekeyChannelState('missing', 'B1')).not.toThrow(); + }); +}); diff --git a/packages/web/src/stores/chatStore.ts b/packages/web/src/stores/chatStore.ts index 3d75cb9a..cd48831e 100644 --- a/packages/web/src/stores/chatStore.ts +++ b/packages/web/src/stores/chatStore.ts @@ -63,6 +63,7 @@ interface ChatState { onChannelAck: (channelId: string, messageId: string) => void; onMarkUnread: (channelId: string, messageId: string) => void; removeChannelStates: (channelIds: Set) => void; + rekeyChannelState: (oldId: string, newId: string) => void; updateUserInMessages: (user: { id: string; [key: string]: any }) => void; clearTypingForUser: (userId: string) => void; } @@ -708,6 +709,44 @@ export const useChatStore = create((set, get) => ({ }); }, + rekeyChannelState: (oldId: string, newId: string) => { + set((state) => { + const copyDelete = (src: Map): Map => { + if (!src.has(oldId)) return src; + const next = new Map(src); + next.delete(oldId); + return next; + }; + + const messages = copyDelete(state.messages); + const typingUsers = copyDelete(state.typingUsers); + const hasMore = copyDelete(state.hasMore); + const readStates = copyDelete(state.readStates); + const channelAccessTimes = copyDelete(state.channelAccessTimes); + const scrollPositions = copyDelete(state.scrollPositions); + + let unreadChannels = state.unreadChannels; + if (state.unreadChannels.has(oldId)) { + unreadChannels = new Set(state.unreadChannels); + unreadChannels.delete(oldId); + unreadChannels.add(newId); + } + + const currentChannelId = state.currentChannelId === oldId ? newId : state.currentChannelId; + + return { + messages, + typingUsers, + hasMore, + readStates, + channelAccessTimes, + scrollPositions, + unreadChannels, + currentChannelId, + }; + }); + }, + updateUserInMessages: (user: { id: string; homeUserId?: string | null; [key: string]: any }) => { set((state) => { const newMessages = new Map(state.messages); 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 4/9] 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). From d393a870c228e1c18a9a6e1f943a66f6374f4582 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:13:48 +0200 Subject: [PATCH 5/9] feat(federation): dmOriginFailover utility (rekey + failover) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit failoverDmOriginsFromDisconnected(origin) walks pinned DMs and re-keys them to a connected sibling origin's local channel id (via dmAlternatives federatedId lookup). Preference: home first, then any connected remote in insertion order. rekeyDmChannel performs the atomic rename across spaceStore (dmChannels / channelOriginMap / channelLastMessageIds / dmAlternatives), chatStore (via rekeyChannelState), and the URL (via history.replaceState when viewing the rekeyed DM). Voice state is intentionally untouched — LiveKit sessions can't migrate across origins. Old origin's local id is retained in dmAlternatives for possible later fail-back without another ready round-trip. --- .../web/src/utils/dmOriginFailover.test.ts | 314 ++++++++++++++++++ packages/web/src/utils/dmOriginFailover.ts | 127 +++++++ 2 files changed, 441 insertions(+) create mode 100644 packages/web/src/utils/dmOriginFailover.test.ts create mode 100644 packages/web/src/utils/dmOriginFailover.ts diff --git a/packages/web/src/utils/dmOriginFailover.test.ts b/packages/web/src/utils/dmOriginFailover.test.ts new file mode 100644 index 00000000..c31eafd2 --- /dev/null +++ b/packages/web/src/utils/dmOriginFailover.test.ts @@ -0,0 +1,314 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Mock useWebSocket imports that instanceStore / chatStore transitively depend on. +vi.mock('../hooks/useWebSocket', () => ({ + wsSend: vi.fn(), + wsSendAll: vi.fn(), + connectInstance: vi.fn(), + disconnectInstance: vi.fn(), + disconnectAllRemote: vi.fn(), +})); +vi.mock('../utils/federationOps', () => ({ + clearPasswordSyncTimers: vi.fn(), +})); + +// Stub AudioManager to avoid AudioWorkletNode reference error in jsdom. +vi.mock('../audio/AudioManager', () => ({ + AudioManager: { + getInstance: vi.fn().mockReturnValue({ + setOutputDevice: vi.fn(), + setVolume: vi.fn(), + }), + }, +})); + +// Stub authStore to avoid localStorage access during module init. +vi.mock('../stores/authStore', () => ({ + useAuthStore: Object.assign( + (selector: (s: unknown) => unknown) => selector({ user: null, token: null }), + { + getState: () => ({ user: null, token: null }), + setState: vi.fn(), + subscribe: vi.fn(), + } + ), +})); + +// Stub instanceStore to avoid TDZ crash from the module-level +// setApiForOriginResolver() call in instanceStore.ts. +// We use a factory so vitest hoisting sees a self-contained mock. +vi.mock('../stores/instanceStore', async () => { + const { create } = await import('zustand'); + const store = create<{ instances: unknown[] }>()(() => ({ instances: [] })); + return { useInstanceStore: store }; +}); + +// Stub voiceStore to avoid Zustand persist localStorage issues in jsdom. +// The test only needs getState()/setState() to verify voice state is untouched. +vi.mock('../stores/voiceStore', async () => { + const { create } = await import('zustand'); + const store = create<{ + activeDmCall: { dmChannelId: string } | null; + outgoingCall: { dmChannelId: string } | null; + incomingCall: unknown; + }>()(() => ({ + activeDmCall: null, + outgoingCall: null, + incomingCall: null, + })); + return { useVoiceStore: store }; +}); + +import { useSpaceStore } from '../stores/spaceStore'; +import { useChatStore } from '../stores/chatStore'; +import { useInstanceStore } from '../stores/instanceStore'; +import { failoverDmOriginsFromDisconnected } from './dmOriginFailover'; +import type { DmChannel, User } from '@backspace/shared'; +import type { ConnectedInstance as Inst } from '../stores/instanceStore'; + +function makeDm(id: string, federatedId: string | null): DmChannel { + return { id, federatedId, createdAt: 1000, members: [] }; +} + +function fakeUser(id: string): User { + return { + id, username: id, displayName: null, avatar: null, accentColor: null, + banner: null, bio: null, status: 'online', activities: [], createdAt: 1, + homeInstance: null, homeUserId: null, + } as any; +} + +function fakeInstance(origin: string, status: Inst['status']): Inst { + return { + origin, + label: origin, + token: 'tok', + user: fakeUser(`u-${origin}`), + username: 'u', + status, + api: {} as any, + }; +} + +beforeEach(() => { + useSpaceStore.getState().reset(); + useChatStore.setState({ + messages: new Map(), + typingUsers: new Map(), + hasMore: new Map(), + readStates: new Map(), + unreadChannels: new Set(), + channelAccessTimes: new Map(), + scrollPositions: new Map(), + currentChannelId: null, + }); + useInstanceStore.setState({ instances: [] }); + // Replace history.replaceState to observe URL writes in tests. + vi.spyOn(window.history, 'replaceState').mockImplementation(() => {}); +}); + +describe('failoverDmOriginsFromDisconnected', () => { + it('no-ops when disconnected origin has no DMs pinned to it', () => { + useSpaceStore.setState({ + dmChannels: [makeDm('home-1', 'fed-aaa')], + channelOriginMap: new Map([['home-1', '']]), + dmAlternatives: new Map([['fed-aaa', new Map([['', 'home-1']])]]), + }); + useInstanceStore.setState({ instances: [fakeInstance('https://b.example', 'disconnected')] }); + + failoverDmOriginsFromDisconnected('https://b.example'); + + expect(useSpaceStore.getState().channelOriginMap.get('home-1')).toBe(''); + }); + + it('re-keys a DM to the connected home alternative', () => { + useSpaceStore.setState({ + dmChannels: [makeDm('b-1', 'fed-aaa')], + channelOriginMap: new Map([['b-1', 'https://b.example']]), + channelLastMessageIds: new Map([['b-1', 'msg-last-on-b']]), + dmAlternatives: new Map([ + ['fed-aaa', new Map([ + ['https://b.example', 'b-1'], + ['', 'home-1'], + ])], + ]), + }); + useInstanceStore.setState({ instances: [fakeInstance('https://b.example', 'disconnected')] }); + useChatStore.setState({ + messages: new Map([['b-1', []]]), + readStates: new Map([['b-1', 'prev']]), + unreadChannels: new Set(['b-1']), + currentChannelId: 'b-1', + }); + + failoverDmOriginsFromDisconnected('https://b.example'); + + const sp = useSpaceStore.getState(); + expect(sp.dmChannels.find(d => d.id === 'home-1')).toBeTruthy(); + expect(sp.dmChannels.find(d => d.id === 'b-1')).toBeUndefined(); + expect(sp.channelOriginMap.get('home-1')).toBe(''); + expect(sp.channelOriginMap.has('b-1')).toBe(false); + expect(sp.channelLastMessageIds.has('b-1')).toBe(false); + + const ch = useChatStore.getState(); + expect(ch.messages.has('b-1')).toBe(false); + expect(ch.readStates.has('b-1')).toBe(false); + expect(ch.unreadChannels.has('home-1')).toBe(true); + expect(ch.unreadChannels.has('b-1')).toBe(false); + expect(ch.currentChannelId).toBe('home-1'); + }); + + it('prefers home (empty-string origin) when multiple alternatives are connected', () => { + useSpaceStore.setState({ + dmChannels: [makeDm('b-1', 'fed-aaa')], + channelOriginMap: new Map([['b-1', 'https://b.example']]), + dmAlternatives: new Map([ + ['fed-aaa', new Map([ + ['https://b.example', 'b-1'], + ['https://c.example', 'c-1'], + ['', 'home-1'], + ])], + ]), + }); + useInstanceStore.setState({ + instances: [ + fakeInstance('https://b.example', 'disconnected'), + fakeInstance('https://c.example', 'connected'), + ], + }); + + failoverDmOriginsFromDisconnected('https://b.example'); + + expect(useSpaceStore.getState().channelOriginMap.get('home-1')).toBe(''); + }); + + it('falls back to a connected remote when home is not an alternative', () => { + useSpaceStore.setState({ + dmChannels: [makeDm('b-1', 'fed-aaa')], + channelOriginMap: new Map([['b-1', 'https://b.example']]), + dmAlternatives: new Map([ + ['fed-aaa', new Map([ + ['https://b.example', 'b-1'], + ['https://c.example', 'c-1'], + ])], + ]), + }); + useInstanceStore.setState({ + instances: [ + fakeInstance('https://b.example', 'disconnected'), + fakeInstance('https://c.example', 'connected'), + ], + }); + + failoverDmOriginsFromDisconnected('https://b.example'); + + const sp = useSpaceStore.getState(); + expect(sp.channelOriginMap.get('c-1')).toBe('https://c.example'); + expect(sp.dmChannels.find(d => d.id === 'c-1')).toBeTruthy(); + }); + + it('leaves the pin untouched when no alternatives are connected', () => { + useSpaceStore.setState({ + dmChannels: [makeDm('b-1', 'fed-aaa')], + channelOriginMap: new Map([['b-1', 'https://b.example']]), + dmAlternatives: new Map([ + ['fed-aaa', new Map([ + ['https://b.example', 'b-1'], + ['https://c.example', 'c-1'], + ])], + ]), + }); + useInstanceStore.setState({ + instances: [ + fakeInstance('https://b.example', 'disconnected'), + fakeInstance('https://c.example', 'error'), + ], + }); + + failoverDmOriginsFromDisconnected('https://b.example'); + + expect(useSpaceStore.getState().channelOriginMap.get('b-1')).toBe('https://b.example'); + expect(useSpaceStore.getState().dmChannels.find(d => d.id === 'b-1')).toBeTruthy(); + }); + + it('skips DMs without a federatedId', () => { + useSpaceStore.setState({ + dmChannels: [makeDm('b-local', null)], + channelOriginMap: new Map([['b-local', 'https://b.example']]), + dmAlternatives: new Map(), + }); + useInstanceStore.setState({ instances: [fakeInstance('https://b.example', 'disconnected')] }); + + failoverDmOriginsFromDisconnected('https://b.example'); + + expect(useSpaceStore.getState().channelOriginMap.get('b-local')).toBe('https://b.example'); + }); + + it('retains oldOrigin → oldLocalId in dmAlternatives after rekey for fail-back', () => { + useSpaceStore.setState({ + dmChannels: [makeDm('b-1', 'fed-aaa')], + channelOriginMap: new Map([['b-1', 'https://b.example']]), + dmAlternatives: new Map([ + ['fed-aaa', new Map([ + ['https://b.example', 'b-1'], + ['', 'home-1'], + ])], + ]), + }); + useInstanceStore.setState({ instances: [fakeInstance('https://b.example', 'disconnected')] }); + + failoverDmOriginsFromDisconnected('https://b.example'); + + const alts = useSpaceStore.getState().dmAlternatives.get('fed-aaa'); + // Old primary (b) retained as alternative for possible future fail-back. + expect(alts?.get('https://b.example')).toBe('b-1'); + // Alternative map entry for the new primary's own origin is removed — it IS the primary now. + expect(alts?.has('')).toBe(false); + }); + + it('updates the URL via history.replaceState when rekeying the current channel', () => { + window.history.pushState({}, '', '/channels/@me/b-1'); + useSpaceStore.setState({ + dmChannels: [makeDm('b-1', 'fed-aaa')], + channelOriginMap: new Map([['b-1', 'https://b.example']]), + dmAlternatives: new Map([ + ['fed-aaa', new Map([ + ['https://b.example', 'b-1'], + ['', 'home-1'], + ])], + ]), + }); + useInstanceStore.setState({ instances: [fakeInstance('https://b.example', 'disconnected')] }); + useChatStore.setState({ currentChannelId: 'b-1' }); + + failoverDmOriginsFromDisconnected('https://b.example'); + + expect(window.history.replaceState).toHaveBeenCalledWith( + expect.anything(), + '', + expect.stringContaining('/channels/@me/home-1'), + ); + }); + + it('does not touch voice state during failover (voice is handled separately)', async () => { + useSpaceStore.setState({ + dmChannels: [makeDm('b-1', 'fed-aaa')], + channelOriginMap: new Map([['b-1', 'https://b.example']]), + dmAlternatives: new Map([ + ['fed-aaa', new Map([['https://b.example', 'b-1'], ['', 'home-1']])], + ]), + }); + useInstanceStore.setState({ instances: [fakeInstance('https://b.example', 'disconnected')] }); + + const { useVoiceStore } = await import('../stores/voiceStore'); + const beforeActive = useVoiceStore.getState().activeDmCall; + const beforeOutgoing = useVoiceStore.getState().outgoingCall; + useVoiceStore.setState({ activeDmCall: { dmChannelId: 'b-1' } }); + + failoverDmOriginsFromDisconnected('https://b.example'); + + expect(useVoiceStore.getState().activeDmCall?.dmChannelId).toBe('b-1'); + // Restore + useVoiceStore.setState({ activeDmCall: beforeActive, outgoingCall: beforeOutgoing }); + }); +}); diff --git a/packages/web/src/utils/dmOriginFailover.ts b/packages/web/src/utils/dmOriginFailover.ts new file mode 100644 index 00000000..38fc99c1 --- /dev/null +++ b/packages/web/src/utils/dmOriginFailover.ts @@ -0,0 +1,127 @@ +import { useSpaceStore } from '../stores/spaceStore'; +import { useChatStore } from '../stores/chatStore'; +import { useInstanceStore } from '../stores/instanceStore'; + +/** + * DM origin failover: when a remote instance disconnects mid-session, + * re-point every DM pinned to it onto a connected sibling that holds the + * same federated DM (via S2S mirroring). See + * `docs/superpowers/specs/2026-04-23-dm-origin-failover-design.md`. + * + * No-op when: + * - no DMs are pinned to the disconnected origin + * - pinned DMs have no `federatedId` (never-federated) + * - no alternative origin is currently connected + * + * Voice state (activeDmCall / outgoingCall / incomingCall) is intentionally + * NOT rewritten — a LiveKit session bound to the disconnected origin cannot + * migrate; voice cleans up through its own disconnect paths. + */ +export function failoverDmOriginsFromDisconnected(disconnectedOrigin: string): void { + const spaceState = useSpaceStore.getState(); + const { dmChannels, channelOriginMap, dmAlternatives } = spaceState; + + // Build the set of connected-origin candidates (home is always considered + // connected while auth is live — its WS disconnect path doesn't go through + // instanceStore at all). + const instances = useInstanceStore.getState().instances; + const connectedRemotes = new Set( + instances.filter(i => i.status === 'connected').map(i => i.origin), + ); + const isOriginConnected = (o: string): boolean => o === '' || connectedRemotes.has(o); + + type Rekey = { oldId: string; newId: string; newOrigin: string; federatedId: string }; + const rekeys: Rekey[] = []; + + for (const dm of dmChannels) { + if (channelOriginMap.get(dm.id) !== disconnectedOrigin) continue; + if (!dm.federatedId) continue; + + const byOrigin = dmAlternatives.get(dm.federatedId); + if (!byOrigin) continue; + + // Preference order: home ('') first, then any connected remote in + // insertion order (Map preserves insertion order in ES2015+). + let chosenOrigin: string | null = null; + let chosenLocalId: string | null = null; + if (byOrigin.has('') && isOriginConnected('') && '' !== disconnectedOrigin) { + chosenOrigin = ''; + chosenLocalId = byOrigin.get('')!; + } else { + for (const [altOrigin, altLocalId] of byOrigin) { + if (altOrigin === disconnectedOrigin) continue; + if (!isOriginConnected(altOrigin)) continue; + chosenOrigin = altOrigin; + chosenLocalId = altLocalId; + break; + } + } + if (chosenOrigin === null || chosenLocalId === null) continue; + if (chosenLocalId === dm.id) continue; // shouldn't happen, but guard + + rekeys.push({ + oldId: dm.id, + newId: chosenLocalId, + newOrigin: chosenOrigin, + federatedId: dm.federatedId, + }); + } + + if (rekeys.length === 0) return; + + for (const r of rekeys) { + rekeyDmChannel(r.oldId, r.newId, r.newOrigin, r.federatedId); + } +} + +/** + * Atomic rename of a DM across spaceStore + chatStore + URL. + * Exported for unit tests; not part of the public failover API. + */ +export function rekeyDmChannel( + oldId: string, + newId: string, + newOrigin: string, + federatedId: string, +): void { + useSpaceStore.setState((state) => { + const dmChannels = state.dmChannels.map(dm => + dm.id === oldId ? { ...dm, id: newId } : dm, + ); + + const channelOriginMap = new Map(state.channelOriginMap); + channelOriginMap.delete(oldId); + channelOriginMap.set(newId, newOrigin); + + const channelLastMessageIds = new Map(state.channelLastMessageIds); + channelLastMessageIds.delete(oldId); // old id's stored last-message id is origin-local + + // dmAlternatives update: remove the chosen origin's entry from the inner map + // (it IS the primary now — not an alternative), but RETAIN every other origin's + // entry including the old origin (so a later fail-back can use it without + // needing another ready round-trip). + const dmAlternatives = new Map>(); + for (const [fid, byOrigin] of state.dmAlternatives) { + const next = new Map(byOrigin); + if (fid === federatedId) next.delete(newOrigin); + if (next.size > 0) dmAlternatives.set(fid, next); + } + + return { dmChannels, channelOriginMap, channelLastMessageIds, dmAlternatives }; + }); + + useChatStore.getState().rekeyChannelState(oldId, newId); + + // URL update: if the user is viewing the DM whose id just changed, swap the + // last path segment in place. No router navigation — the chat view re-renders + // from the updated currentChannelId. + if (typeof window !== 'undefined') { + const path = window.location.pathname; + const marker = `/channels/@me/`; + const idx = path.indexOf(marker); + if (idx !== -1 && path.slice(idx + marker.length) === oldId) { + const nextPath = path.slice(0, idx + marker.length) + newId; + window.history.replaceState(window.history.state, '', nextPath + window.location.search); + } + } +} From 1a2387136796f529ddfc6509f6e6595706e36102 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:22:06 +0200 Subject: [PATCH 6/9] feat(federation): trigger DM failover on setInstanceStatus transition When an instance transitions from 'connected' to 'disconnected' or 'error', fire failoverDmOriginsFromDisconnected for that origin. Dynamic import preserves the circular-dep-safe resolver pattern used elsewhere in instanceStore. Fire-and-forget; the failover utility reads fresh state at call time. --- .../src/stores/instanceStore.failover.test.ts | 70 +++++++++++++++++++ packages/web/src/stores/instanceStore.ts | 8 +++ 2 files changed, 78 insertions(+) create mode 100644 packages/web/src/stores/instanceStore.failover.test.ts diff --git a/packages/web/src/stores/instanceStore.failover.test.ts b/packages/web/src/stores/instanceStore.failover.test.ts new file mode 100644 index 00000000..5952fbf1 --- /dev/null +++ b/packages/web/src/stores/instanceStore.failover.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +const mockFailover = vi.fn(); +vi.mock('../utils/dmOriginFailover', () => ({ + failoverDmOriginsFromDisconnected: (o: string) => mockFailover(o), +})); +vi.mock('../hooks/useWebSocket', () => ({ + connectInstance: vi.fn(), + disconnectInstance: vi.fn(), + disconnectAllRemote: vi.fn(), +})); +vi.mock('../utils/federationOps', () => ({ clearPasswordSyncTimers: vi.fn() })); +// Stub AudioManager to avoid AudioWorkletNode reference error in jsdom +vi.mock('../audio/AudioManager', () => ({ + AudioManager: { getInstance: vi.fn().mockReturnValue({ setOutputDevice: vi.fn(), setVolume: vi.fn() }) }, +})); +// Stub authStore to avoid localStorage access during module init +vi.mock('./authStore', () => ({ + useAuthStore: Object.assign( + (selector: (s: unknown) => unknown) => selector({ user: null, token: null }), + { getState: () => ({ user: null, token: null }), setState: vi.fn(), subscribe: vi.fn() } + ), +})); + +import { useInstanceStore } from './instanceStore'; +import type { ConnectedInstance } from './instanceStore'; + +function inst(origin: string, status: ConnectedInstance['status']): ConnectedInstance { + return { + origin, label: origin, token: 'tok', username: 'u', status, + user: { id: 'u', username: 'u' } as any, + api: {} as any, + }; +} + +beforeEach(() => { + mockFailover.mockClear(); + useInstanceStore.setState({ instances: [], registry: new Map(), registryUpdatedAt: 0 }); +}); + +describe('instanceStore failover triggers', () => { + it('fires failover on connected → disconnected transition', async () => { + useInstanceStore.setState({ instances: [inst('https://b.example', 'connected')] }); + useInstanceStore.getState().setInstanceStatus('https://b.example', 'disconnected'); + await vi.waitFor(() => expect(mockFailover).toHaveBeenCalledExactlyOnceWith('https://b.example')); + }); + + it('fires failover on connected → error transition', async () => { + useInstanceStore.setState({ instances: [inst('https://b.example', 'connected')] }); + useInstanceStore.getState().setInstanceStatus('https://b.example', 'error'); + await vi.waitFor(() => expect(mockFailover).toHaveBeenCalledExactlyOnceWith('https://b.example')); + }); + + it('does not fire on connecting → connected', () => { + useInstanceStore.setState({ instances: [inst('https://b.example', 'connecting')] }); + useInstanceStore.getState().setInstanceStatus('https://b.example', 'connected'); + expect(mockFailover).not.toHaveBeenCalled(); + }); + + it('does not fire on disconnected → error (no connected source)', () => { + useInstanceStore.setState({ instances: [inst('https://b.example', 'disconnected')] }); + useInstanceStore.getState().setInstanceStatus('https://b.example', 'error'); + expect(mockFailover).not.toHaveBeenCalled(); + }); + + it('does not fire when instance is not in the list', () => { + useInstanceStore.getState().setInstanceStatus('https://unknown.example', 'disconnected'); + expect(mockFailover).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/stores/instanceStore.ts b/packages/web/src/stores/instanceStore.ts index 38ad36b3..7bec2e76 100644 --- a/packages/web/src/stores/instanceStore.ts +++ b/packages/web/src/stores/instanceStore.ts @@ -428,11 +428,19 @@ export const useInstanceStore = create((set, get) => ({ }, setInstanceStatus: (origin, status, error) => { + const prev = get().instances.find(i => i.origin === origin)?.status; set((state) => ({ instances: state.instances.map(i => i.origin === origin ? { ...i, status, error } : i ), })); + if (prev === 'connected' && (status === 'disconnected' || status === 'error')) { + // Dynamic import keeps the circular-dep-safe resolver pattern used elsewhere + // in this file. Fire-and-forget: failover reads state at call time. + import('../utils/dmOriginFailover').then(({ failoverDmOriginsFromDisconnected }) => { + failoverDmOriginsFromDisconnected(origin); + }); + } }, disconnectInstance: (origin: string) => { From cd1f5c2b646f308fae7b70939e92bd47ae978f7f Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:25:42 +0200 Subject: [PATCH 7/9] feat(federation): trigger DM failover on user-initiated disconnect disconnectInstance and forceRemoveEntry now run failoverDmOriginsFromDisconnected BEFORE removeInstanceSpaces so any DM with a connected sibling survives the disconnect via rekey; only DMs without alternatives are cleared alongside the rest of the instance. Switched setInstanceStatus to the same static import (dmOriginFailover lazily reads store state, so no import cycle). --- .../src/stores/instanceStore.failover.test.ts | 40 +++++++++++++++++-- packages/web/src/stores/instanceStore.ts | 17 ++++---- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/packages/web/src/stores/instanceStore.failover.test.ts b/packages/web/src/stores/instanceStore.failover.test.ts index 5952fbf1..7a55b65f 100644 --- a/packages/web/src/stores/instanceStore.failover.test.ts +++ b/packages/web/src/stores/instanceStore.failover.test.ts @@ -39,16 +39,16 @@ beforeEach(() => { }); describe('instanceStore failover triggers', () => { - it('fires failover on connected → disconnected transition', async () => { + it('fires failover on connected → disconnected transition', () => { useInstanceStore.setState({ instances: [inst('https://b.example', 'connected')] }); useInstanceStore.getState().setInstanceStatus('https://b.example', 'disconnected'); - await vi.waitFor(() => expect(mockFailover).toHaveBeenCalledExactlyOnceWith('https://b.example')); + expect(mockFailover).toHaveBeenCalledExactlyOnceWith('https://b.example'); }); - it('fires failover on connected → error transition', async () => { + it('fires failover on connected → error transition', () => { useInstanceStore.setState({ instances: [inst('https://b.example', 'connected')] }); useInstanceStore.getState().setInstanceStatus('https://b.example', 'error'); - await vi.waitFor(() => expect(mockFailover).toHaveBeenCalledExactlyOnceWith('https://b.example')); + expect(mockFailover).toHaveBeenCalledExactlyOnceWith('https://b.example'); }); it('does not fire on connecting → connected', () => { @@ -67,4 +67,36 @@ describe('instanceStore failover triggers', () => { useInstanceStore.getState().setInstanceStatus('https://unknown.example', 'disconnected'); expect(mockFailover).not.toHaveBeenCalled(); }); + + it('disconnectInstance runs failover before removeInstanceSpaces', async () => { + // Seed a DM pinned to b.example with home as alternative — removeInstanceSpaces + // uses spaceStore, which we let run; we just check failover ran first (call order). + const spaceModule = await import('./spaceStore'); + const spaceSpy = vi.spyOn(spaceModule.useSpaceStore.getState(), 'removeInstanceSpaces'); + const callOrder: string[] = []; + mockFailover.mockImplementation(() => { callOrder.push('failover'); }); + spaceSpy.mockImplementation(() => { callOrder.push('removeInstanceSpaces'); }); + + useInstanceStore.setState({ instances: [inst('https://b.example', 'connected')] }); + useInstanceStore.getState().disconnectInstance('https://b.example'); + await Promise.resolve(); + + expect(callOrder).toEqual(['failover', 'removeInstanceSpaces']); + spaceSpy.mockRestore(); + }); + + it('forceRemoveEntry runs failover before removeInstanceSpaces', async () => { + const spaceModule = await import('./spaceStore'); + const spaceSpy = vi.spyOn(spaceModule.useSpaceStore.getState(), 'removeInstanceSpaces'); + const callOrder: string[] = []; + mockFailover.mockImplementation(() => { callOrder.push('failover'); }); + spaceSpy.mockImplementation(() => { callOrder.push('removeInstanceSpaces'); }); + + useInstanceStore.setState({ instances: [inst('https://b.example', 'connected')] }); + useInstanceStore.getState().forceRemoveEntry('https://b.example'); + await Promise.resolve(); + + expect(callOrder).toEqual(['failover', 'removeInstanceSpaces']); + spaceSpy.mockRestore(); + }); }); diff --git a/packages/web/src/stores/instanceStore.ts b/packages/web/src/stores/instanceStore.ts index 7bec2e76..7dcbfa26 100644 --- a/packages/web/src/stores/instanceStore.ts +++ b/packages/web/src/stores/instanceStore.ts @@ -8,6 +8,9 @@ import { connectInstance, disconnectInstance as disconnectWs, disconnectAllRemot // Safe because both modules access each other lazily (at call time, not import time). // clearPasswordSyncTimers itself does not reference useInstanceStore. import { clearPasswordSyncTimers } from '../utils/federationOps'; +// dmOriginFailover lazily reads useInstanceStore/useSpaceStore/useChatStore at call time, +// so a static import here does not create an import-time cycle. +import { failoverDmOriginsFromDisconnected } from '../utils/dmOriginFailover'; import { useUIStore } from './uiStore'; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -435,11 +438,7 @@ export const useInstanceStore = create((set, get) => ({ ), })); if (prev === 'connected' && (status === 'disconnected' || status === 'error')) { - // Dynamic import keeps the circular-dep-safe resolver pattern used elsewhere - // in this file. Fire-and-forget: failover reads state at call time. - import('../utils/dmOriginFailover').then(({ failoverDmOriginsFromDisconnected }) => { - failoverDmOriginsFromDisconnected(origin); - }); + failoverDmOriginsFromDisconnected(origin); } }, @@ -467,7 +466,10 @@ export const useInstanceStore = create((set, get) => ({ return { instances: updated, registry, registryUpdatedAt }; }); - // Remove spaces from this instance from the space store + // Failover DMs to a connected sibling BEFORE removeInstanceSpaces wipes + // this origin's pins. DMs with a connected alternative survive via rekey; + // DMs without one are removed alongside the rest of the instance's content. + failoverDmOriginsFromDisconnected(origin); useSpaceStore.getState().removeInstanceSpaces(origin); // Sync updated lists to remaining instances (fire-and-forget) @@ -757,7 +759,8 @@ export const useInstanceStore = create((set, get) => ({ return { instances: updated, registry, registryUpdatedAt }; }); - // Clean up spaces belonging to this instance + // Same rationale as disconnectInstance — preserve DMs with connected alts. + failoverDmOriginsFromDisconnected(origin); useSpaceStore.getState().removeInstanceSpaces(origin); get().syncRegistry().catch(() => {}); From b1844e126f385ff143d175d9b3566ebfe1f32a42 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:29:28 +0200 Subject: [PATCH 8/9] feat(federation): dmAlternatives fallback in dm_message_created MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new resolution step before the legacy 2-member-identity fallback: if the event's dmChannelId is an alternate-origin local id for a DM whose primary is in dmChannels, route the message to the primary via resolveDmChannelId. Covers 1-on-1 AND group DMs uniformly — closes a pre-existing phantom-sidebar-entry bug for group DMs in multi-instance sessions and handles post-failover routing when the reconnected original origin's WS still addresses the DM by its old local id. --- .../hooks/dmMessageCreated.fallback.test.ts | 70 +++++++++++++++++++ packages/web/src/hooks/useWebSocket.ts | 26 ++++++- 2 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 packages/web/src/hooks/dmMessageCreated.fallback.test.ts diff --git a/packages/web/src/hooks/dmMessageCreated.fallback.test.ts b/packages/web/src/hooks/dmMessageCreated.fallback.test.ts new file mode 100644 index 00000000..81230cd2 --- /dev/null +++ b/packages/web/src/hooks/dmMessageCreated.fallback.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('../hooks/useWebSocket', async () => { + const actual = await vi.importActual('../hooks/useWebSocket'); + return actual; +}); + +// Stub AudioManager to avoid AudioWorkletNode reference error in jsdom +vi.mock('../audio/AudioManager', () => ({ + AudioManager: { + getInstance: vi.fn().mockReturnValue({ + setOutputDevice: vi.fn(), + setVolume: vi.fn(), + }), + }, +})); + +// Stub instanceStore to avoid initialization ordering issues +vi.mock('../stores/instanceStore', () => ({ + useInstanceStore: Object.assign( + (selector: (s: unknown) => unknown) => selector({ instances: [], _autoConnectDone: true }), + { + getState: () => ({ instances: [], _autoConnectDone: true }), + setState: vi.fn(), + subscribe: vi.fn(), + } + ), +})); + +// Stub authStore to avoid localStorage access during module init +vi.mock('../stores/authStore', () => ({ + useAuthStore: Object.assign( + (selector: (s: unknown) => unknown) => selector({ user: null, token: null }), + { + getState: () => ({ user: null, token: null }), + setState: vi.fn(), + subscribe: vi.fn(), + } + ), +})); + +import { useSpaceStore, resolveDmChannelId } from '../stores/spaceStore'; +import type { DmChannel } from '@backspace/shared'; + +function dm(id: string, federatedId: string | null, members: any[] = []): DmChannel { + return { id, federatedId, createdAt: 1000, members }; +} + +beforeEach(() => { useSpaceStore.getState().reset(); }); + +describe('resolveDmChannelId (contract for dm_message_created fallback)', () => { + it('resolves alternate-origin group-DM id to primary (closes pre-existing phantom-entry bug)', () => { + // Group DM: primary pinned to home (home-gdm), alternate on remote (remote-gdm). + useSpaceStore.setState({ + dmChannels: [dm('home-gdm', 'fed-group', [{ id: 'u1' } as any, { id: 'u2' } as any, { id: 'u3' } as any])], + channelOriginMap: new Map([['home-gdm', '']]), + dmAlternatives: new Map([ + ['fed-group', new Map([ + ['', 'home-gdm'], + ['https://remote.example', 'remote-gdm'], + ])], + ]), + }); + + // A dm_message_created event from remote.example carries dmChannelId = 'remote-gdm'. + // The handler's first lookup (dmChannels.find(id === 'remote-gdm')) returns undefined. + // The new dmAlternatives fallback must resolve this to 'home-gdm'. + expect(resolveDmChannelId('remote-gdm')).toBe('home-gdm'); + }); +}); diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 5cd33c53..80fc875c 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -1,6 +1,6 @@ import React, { useEffect, useRef } from 'react'; import { useAuthStore } from '../stores/authStore'; -import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin, setMyUserIdForOrigin } from '../stores/spaceStore'; +import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin, setMyUserIdForOrigin, resolveDmChannelId } from '../stores/spaceStore'; import { useChatStore } from '../stores/chatStore'; import { useVoiceStore } from '../stores/voiceStore'; import { useSocialStore } from '../stores/socialStore'; @@ -679,6 +679,30 @@ function handleEvent(origin: string, event: ServerEvent): void { // (same conversation, different channel ID). If so, skip adding a new sidebar entry // and route the message to the existing channel instead. if (!knownDm) { + // dmAlternatives-based resolution: if this channelId is an alternate-origin + // local id for a DM whose primary is in dmChannels, reroute to the primary. + // Covers 1-on-1 AND group DMs uniformly; also the post-failover path where + // the reconnected original origin's WS still uses its old local id. + const primaryId = resolveDmChannelId(event.message.dmChannelId); + if (primaryId && primaryId !== event.message.dmChannelId) { + addRealtimeMessage(primaryId, { ...event.message, dmChannelId: primaryId } as any); + const updatedDms = currentDmChannels.map(dm => + dm.id === primaryId ? { ...dm, lastMessage: event.message } : dm, + ); + const { unreadChannels: u1, currentChannelId: c1 } = useChatStore.getState(); + setDms(sortDmChannels(updatedDms, u1, c1)); + { + const { currentChannelId: u1cc, markChannelUnread: u1mu } = useChatStore.getState(); + const myId = isHome ? useAuthStore.getState().user?.id : getMyUserIdForOrigin(origin); + if (primaryId !== u1cc && event.message.userId !== myId) { + u1mu(primaryId); + } + } + break; + } + + // Legacy 2-member-identity fallback: covers DMs without a federatedId + // (pre-federation or never-federated 1-on-1 DMs). const msgUser = event.message.user; const msgHomeUserId = msgUser?.homeUserId || msgUser?.id; if (msgHomeUserId) { From de0b6c2a42998c89b20f0fed8274ed8f75915db2 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:31:22 +0200 Subject: [PATCH 9/9] docs(federation): document DM origin failover mechanism New subsection under client-federation.md's origin-aware routing section covering dmAlternatives, failoverDmOriginsFromDisconnected, rekey flow, trigger points, the intentional cache-flush trade-off, voice-out-of-scope, no-re-home policy, and the WS routing contract. dm-system.md gets a one-line cross-reference from the Client routing bullet. --- docs/systems/client-federation.md | 23 +++++++++++++++++++++++ docs/systems/dm-system.md | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/docs/systems/client-federation.md b/docs/systems/client-federation.md index 3bed10d3..b149c65b 100644 --- a/docs/systems/client-federation.md +++ b/docs/systems/client-federation.md @@ -153,6 +153,29 @@ Built during `populateFromReady()` when WS ready events arrive from each instanc > **DM channels** are mapped to the origin of the instance that delivered them in the `ready` event. For 1-on-1 DMs created locally this is typically `''` (home), but federated DMs may arrive from any connected instance. DM read/write operations are routed to the channel's origin via `getApiForOrigin(getChannelOrigin(channelId))`. S2S relay then propagates changes to all other instances that have the same channel. +### DM Origin Failover + +When a remote instance's WebSocket drops mid-session, every DM pinned to that origin is re-keyed to a connected sibling that mirrors the same federated DM (via S2S replication). This keeps DM operations working through a transient disconnect, at the cost of a brief message-cache flush on the rekeyed DMs. + +**Mechanism:** + +- `dmAlternatives: Map>` on `spaceStore` records every origin's local channel ID observed in any `ready` payload, regardless of whether the dedup pass kept that copy in `dmChannels`. +- `failoverDmOriginsFromDisconnected(origin)` in `utils/dmOriginFailover.ts` walks DMs pinned to the disconnected origin, looks up a connected alternate in `dmAlternatives` (preference: home first, then any connected remote in insertion order), and calls `rekeyDmChannel` to atomically rename the DM across `spaceStore`, `chatStore`, and the URL. +- `chatStore.rekeyChannelState(oldId, newId)` deletes all channel-keyed entries for `oldId` (messages, hasMore, scrollPositions, channelAccessTimes, typingUsers, readStates) without seeding `newId` — subscribers re-fetch from the new origin. `unreadChannels` membership transfers only if `oldId` was already unread. `currentChannelId` updates when it matches `oldId`. +- URL: `history.replaceState` swaps the path segment in place when the user is viewing the rekeyed DM — no router navigation. + +**Triggers:** `instanceStore.setInstanceStatus` on `connected → disconnected|error`; `disconnectInstance` and `forceRemoveEntry` call failover before `removeInstanceSpaces` so DMs with connected alternatives survive user-initiated disconnect. + +**Intentional UX trade-off:** on failover, the active DM's message cache is flushed (origin-local message IDs don't match the new origin's responses). A brief "loading" state appears while the chat view re-fetches. Documented intentionally — failover is a recovery path, not the hot path. + +**Voice is out of scope.** LiveKit rooms are bound to the hosting origin and cannot migrate. `voiceStore.activeDmCall` / `outgoingCall` / `incomingCall` are not rewritten by failover; voice state clears through existing LiveKit disconnect paths. + +**No re-home on reconnect:** when the originally pinned origin comes back, its `ready` re-adds its local id to `dmAlternatives` but leaves the new primary in place. Avoids flapping. + +**WS event routing contract:** every DM WS event handler either routes via the primary `dmChannels` id (using `resolveDmChannelId(rawId)`) or silently no-ops on unknown ids. Only `dm_channel_created` creates new `dmChannels` entries — and it dedups by `federatedId` first. + +Source: `utils/dmOriginFailover.ts` + extensions in `stores/spaceStore.ts`, `stores/chatStore.ts`, `stores/instanceStore.ts`, `hooks/useWebSocket.ts`. Design spec: `docs/superpowers/specs/2026-04-23-dm-origin-failover-design.md`. + ### API Client Resolution ```typescript diff --git a/docs/systems/dm-system.md b/docs/systems/dm-system.md index ef0dfc66..245bf385 100644 --- a/docs/systems/dm-system.md +++ b/docs/systems/dm-system.md @@ -79,7 +79,7 @@ The format difference (32-char hex vs 36-char UUID with dashes) allows detecting **No federation event queued at creation time.** The `federatedId` for 1-on-1 DMs is computed on demand when the first message is relayed via `queueDmRelay()`. The receiving instance uses `findOrCreateDmChannel()` which computes the deterministic hash and creates the channel if needed. -**Client routing:** DM creation always goes to the home instance. For federated users, the client passes `{ homeUserId, homeInstance }` and the server resolves the target via `resolveOrCreateReplicatedUser()`. The `federatedId` is computed at creation time when either participant has `homeInstance` set. +**Client routing:** DM creation always goes to the home instance. For federated users, the client passes `{ homeUserId, homeInstance }` and the server resolves the target via `resolveOrCreateReplicatedUser()`. The `federatedId` is computed at creation time when either participant has `homeInstance` set. Post-creation, every DM operation (message send/edit/delete, close/leave, typing, reactions, read-state acks) routes through `getChannelOrigin(channelId)` → `getApiForOrigin(origin)`. If the pinned origin drops mid-session, client-side failover re-keys the DM to a connected sibling that mirrors the same `federatedId` — see `docs/systems/client-federation.md` "DM Origin Failover". ---