feat(federation): dmOriginFailover utility (rekey + failover)
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.
This commit is contained in:
@@ -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<string, Map<string, string>>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user