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).
This commit is contained in:
@@ -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> = {}): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,6 +41,8 @@ interface SpaceState {
|
|||||||
channelOriginMap: Map<string, string>; // channelId → instance origin ('' = home)
|
channelOriginMap: Map<string, string>; // channelId → instance origin ('' = home)
|
||||||
voiceChannelIds: Set<string>; // channelIds that are voice channels (excluded from unread)
|
voiceChannelIds: Set<string>; // channelIds that are voice channels (excluded from unread)
|
||||||
categoryOriginMap: Map<string, string>; // categoryId → instance origin ('' = home)
|
categoryOriginMap: Map<string, string>; // 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<string, Map<string, string>>;
|
||||||
loadingSpaceId: string | null; // non-null while loadSpaceDetail is fetching
|
loadingSpaceId: string | null; // non-null while loadSpaceDetail is fetching
|
||||||
_layoutUpdatedAt: number;
|
_layoutUpdatedAt: number;
|
||||||
setSpaces: (spaces: TaggedSpace[]) => void;
|
setSpaces: (spaces: TaggedSpace[]) => void;
|
||||||
@@ -132,6 +134,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
channelOriginMap: new Map(),
|
channelOriginMap: new Map(),
|
||||||
voiceChannelIds: new Set(),
|
voiceChannelIds: new Set(),
|
||||||
categoryOriginMap: new Map(),
|
categoryOriginMap: new Map(),
|
||||||
|
dmAlternatives: new Map(),
|
||||||
loadingSpaceId: null,
|
loadingSpaceId: null,
|
||||||
_layoutUpdatedAt: 0,
|
_layoutUpdatedAt: 0,
|
||||||
|
|
||||||
@@ -154,6 +157,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
channelOriginMap: new Map(),
|
channelOriginMap: new Map(),
|
||||||
voiceChannelIds: new Set(),
|
voiceChannelIds: new Set(),
|
||||||
categoryOriginMap: new Map(),
|
categoryOriginMap: new Map(),
|
||||||
|
dmAlternatives: new Map(),
|
||||||
loadingSpaceId: null,
|
loadingSpaceId: null,
|
||||||
_layoutUpdatedAt: 0,
|
_layoutUpdatedAt: 0,
|
||||||
});
|
});
|
||||||
@@ -584,6 +588,10 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
const channelOriginMap = new Map(get().channelOriginMap);
|
const channelOriginMap = new Map(get().channelOriginMap);
|
||||||
const voiceChannelIds = new Set(get().voiceChannelIds);
|
const voiceChannelIds = new Set(get().voiceChannelIds);
|
||||||
const categoryOriginMap = new Map(get().categoryOriginMap);
|
const categoryOriginMap = new Map(get().categoryOriginMap);
|
||||||
|
const dmAlternatives = new Map<string, Map<string, string>>();
|
||||||
|
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 home, clear home-origin entries first to avoid stale data
|
||||||
if (isHome) {
|
if (isHome) {
|
||||||
@@ -688,6 +696,18 @@ export const useSpaceStore = create<SpaceState>((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
|
// Merge: remove DMs belonging to this origin from existing state, then append incoming
|
||||||
const existingDmsFromOtherOrigins = get().dmChannels.filter(dm => {
|
const existingDmsFromOtherOrigins = get().dmChannels.filter(dm => {
|
||||||
const dmOrigin = get().channelOriginMap.get(dm.id);
|
const dmOrigin = get().channelOriginMap.get(dm.id);
|
||||||
@@ -711,6 +731,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
channelOriginMap,
|
channelOriginMap,
|
||||||
voiceChannelIds,
|
voiceChannelIds,
|
||||||
categoryOriginMap,
|
categoryOriginMap,
|
||||||
|
dmAlternatives,
|
||||||
};
|
};
|
||||||
|
|
||||||
// LWW layout merge: accept incoming layout only if its timestamp is >= ours
|
// LWW layout merge: accept incoming layout only if its timestamp is >= ours
|
||||||
|
|||||||
Reference in New Issue
Block a user