feat: LWW timestamps for bidirectional federation profile & layout sync

Profile and space layout changes on remote instances were being
overwritten by stale data on reconnect. Adds Last-Writer-Wins
timestamps so the client-relay mesh rejects stale writes:

- profile_updated_at column on users table with migration + backfill
- Server LWW guards on PATCH /users/@me and PUT /space-layout
- Bidirectional profileSync: pulls newer remote profiles to home
- LWW layout sync replaces home-authoritative _layoutFromTrueHome flag
- Layout pushes to ALL connected instances in parallel
This commit is contained in:
Jannis Braun
2026-03-12 18:28:37 +01:00
parent acbcf4d4e8
commit 83699d7e91
10 changed files with 294 additions and 115 deletions
+2 -2
View File
@@ -67,7 +67,7 @@ export class BackspaceApiClient {
};
readonly spaceLayout: {
update: (data: { items: SpaceLayoutItem[]; folders: Record<string, { name: string | null; color: string | null; spaceIds: string[] }> }) => Promise<{ items: SpaceLayoutItem[]; folders: SpaceFolder[] }>;
update: (data: { items: SpaceLayoutItem[]; folders: Record<string, { name: string | null; color: string | null; spaceIds: string[] }>; updatedAt?: number }) => Promise<{ items: SpaceLayoutItem[]; folders: SpaceFolder[]; updatedAt?: number }>;
};
readonly spaces: {
@@ -278,7 +278,7 @@ export class BackspaceApiClient {
this.spaceLayout = {
update: (data) =>
request<{ items: SpaceLayoutItem[]; folders: SpaceFolder[] }>('PUT', '/users/@me/space-layout', data),
request<{ items: SpaceLayoutItem[]; folders: SpaceFolder[]; updatedAt?: number }>('PUT', '/users/@me/space-layout', data),
};
this.spaces = {
+8 -10
View File
@@ -107,7 +107,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
}
}
populateFromReady(origin, event.spaces, event.folders, event.dmChannels, event.spaceLayout);
populateFromReady(origin, event.spaces, event.folders, event.dmChannels, event.spaceLayout, event.layoutUpdatedAt);
// Cache authoritative identity for this origin (federation-safe)
if (!isHome) {
@@ -698,15 +698,13 @@ function handleEvent(origin: string, event: ServerEvent): void {
}
case 'space_layout_updated': {
// Accept layout updates from browsing instance OR true home
const layoutUser = useAuthStore.getState().user;
const isLayoutTrueHome = !!layoutUser?.homeInstance && origin !== '' && (() => {
try { return new URL(origin).host === layoutUser.homeInstance; } catch { return false; }
})();
if (!isHome && !isLayoutTrueHome) break;
const { setSpaceLayout } = useSpaceStore.getState();
setSpaceLayout(event.layout);
useSpaceStore.setState({ folders: event.folders });
// LWW: only accept if incoming timestamp >= current
const incomingTs = event.updatedAt ?? 0;
const currentTs = useSpaceStore.getState()._layoutUpdatedAt;
if (incomingTs >= currentTs) {
useSpaceStore.getState().setSpaceLayout(event.layout);
useSpaceStore.setState({ folders: event.folders, _layoutUpdatedAt: incomingTs });
}
break;
}
+69 -50
View File
@@ -38,7 +38,7 @@ interface SpaceState {
channelPermissions: Map<string, string>; // channelId → myPermissions decimal string
channelOriginMap: Map<string, string>; // channelId → instance origin ('' = home)
categoryOriginMap: Map<string, string>; // categoryId → instance origin ('' = home)
_layoutFromTrueHome: boolean;
_layoutUpdatedAt: number;
setSpaces: (spaces: TaggedSpace[]) => void;
setCurrentSpace: (spaceId: string | null) => void;
setChannels: (channels: Channel[]) => void;
@@ -76,13 +76,40 @@ interface SpaceState {
removeMember: (userId: string) => void;
setSpaceLayout: (layout: SpaceLayoutItem[] | null) => void;
updateSpaceLayout: (items: SpaceLayoutItem[], folders: Record<string, { name: string | null; color: string | null; spaceIds: string[] }>) => Promise<void>;
populateFromReady: (origin: string, spaces: SpaceWithChannelsAndMembers[], folders?: SpaceFolder[], dmChannels?: DmChannel[], spaceLayout?: SpaceLayoutItem[] | null) => void;
populateFromReady: (origin: string, spaces: SpaceWithChannelsAndMembers[], folders?: SpaceFolder[], dmChannels?: DmChannel[], spaceLayout?: SpaceLayoutItem[] | null, layoutUpdatedAt?: number) => void;
addSpaceFromReady: (origin: string, space: SpaceWithChannelsAndMembers) => void;
removeInstanceSpaces: (origin: string) => void;
transferOwnership: (spaceId: string, newOwnerId: string) => Promise<void>;
findExistingDmForUser: (targetUser: { id: string; homeUserId?: string | null }) => { dm: DmChannel; origin: string } | null;
}
/**
* Push the current layout to a specific origin whose layout was older.
* Used when populateFromReady receives a stale layout from an instance.
*/
async function pushLayoutToOrigin(
origin: string,
layout: SpaceLayoutItem[] | null,
folders: SpaceFolder[],
updatedAt: number,
): Promise<void> {
try {
const targetApi = getApiForOrigin(origin);
// Build folder map from SpaceFolder[]
const folderMap: Record<string, { name: string | null; color: string | null; spaceIds: string[] }> = {};
for (const f of folders) {
folderMap[f.id] = { name: f.name, color: f.color, spaceIds: f.spaceIds };
}
await targetApi.spaceLayout.update({
items: layout ?? [],
folders: folderMap,
updatedAt,
});
} catch (err) {
console.warn(`[SpaceStore] Failed to push layout to ${origin || 'home'}:`, err);
}
}
export const useSpaceStore = create<SpaceState>((set, get) => ({
spaces: [],
currentSpaceId: null,
@@ -99,7 +126,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
channelPermissions: new Map(),
channelOriginMap: new Map(),
categoryOriginMap: new Map(),
_layoutFromTrueHome: false,
_layoutUpdatedAt: 0,
setSpaces: (spaces) => set({ spaces }),
setCurrentSpace: (spaceId) => set({ currentSpaceId: spaceId }),
@@ -424,33 +451,43 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
setSpaceLayout: (layout) => set({ spaceLayout: layout }),
updateSpaceLayout: async (items, folders) => {
// Optimistic: apply the layout immediately
set({ spaceLayout: items });
const now = Date.now();
// Optimistic: apply the layout immediately with new timestamp
set({ spaceLayout: items, _layoutUpdatedAt: now });
const homeOrigin = getLayoutHomeOrigin();
const homeApi = getApiForOrigin(homeOrigin);
// Push to ALL connected instances in parallel (browsing + remotes)
const targets: { origin: string; apiClient: BackspaceApiClient }[] = [
{ origin: '', apiClient: api },
];
// Dynamically import instanceStore to avoid circular dep
try {
const result = await homeApi.spaceLayout.update({ items, folders });
// Server may have resolved new:* IDs
set({ spaceLayout: result.items, folders: result.folders });
} catch (err) {
// If true home is remote and unreachable, fall back to browsing instance
if (homeOrigin) {
console.warn(`Layout save to home (${homeOrigin}) failed, falling back to local:`, err);
try {
const result = await api.spaceLayout.update({ items, folders });
set({ spaceLayout: result.items, folders: result.folders });
} catch (fallbackErr) {
console.error('Failed to save space layout:', fallbackErr);
}
} else {
console.error('Failed to save space layout:', err);
const { useInstanceStore } = await import('./instanceStore');
const connected = useInstanceStore.getState().instances.filter(i => i.status === 'connected');
for (const inst of connected) {
targets.push({ origin: inst.origin, apiClient: inst.api });
}
} catch { /* instanceStore not available yet */ }
const results = await Promise.allSettled(
targets.map(t => t.apiClient.spaceLayout.update({ items, folders, updatedAt: now }))
);
// Use the first successful response to resolve new:* IDs
for (const result of results) {
if (result.status === 'fulfilled') {
const resolved = result.value;
set({
spaceLayout: resolved.items,
folders: resolved.folders,
_layoutUpdatedAt: resolved.updatedAt ?? now,
});
break;
}
}
},
populateFromReady: (origin: string, spaces: SpaceWithChannelsAndMembers[], folders?: SpaceFolder[], dmChannels?: DmChannel[], spaceLayout?: SpaceLayoutItem[] | null) => {
populateFromReady: (origin: string, spaces: SpaceWithChannelsAndMembers[], folders?: SpaceFolder[], dmChannels?: DmChannel[], spaceLayout?: SpaceLayoutItem[] | null, layoutUpdatedAt?: number) => {
const isHome = !origin;
// Tag all incoming servers with their instance origin
@@ -568,26 +605,19 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
categoryOriginMap,
};
// Determine if this origin is the user's true home (federation-aware)
const currentUser = useAuthStore.getState().user;
const isTrueHome = !!currentUser?.homeInstance && origin !== '' && (() => {
try { return new URL(origin).host === currentUser.homeInstance; } catch { return false; }
})();
// Accept layout from true home (authoritative) or browsing instance (fallback)
if (isTrueHome) {
// Authoritative: true home always wins
update.folders = folders || [];
if (spaceLayout !== undefined) {
update.spaceLayout = spaceLayout ?? null;
}
update._layoutFromTrueHome = true;
} else if (isHome && !get()._layoutFromTrueHome) {
// Fallback: browsing instance's layout, only until true home connects
// LWW layout merge: accept incoming layout only if its timestamp is >= ours
const incomingTs = layoutUpdatedAt ?? 0;
const currentTs = get()._layoutUpdatedAt;
if (incomingTs >= currentTs) {
// Incoming is same age or newer — accept
update.folders = folders || [];
if (spaceLayout !== undefined) {
update.spaceLayout = spaceLayout ?? null;
}
(update as any)._layoutUpdatedAt = incomingTs;
} else {
// Our layout is newer — push back to this instance
pushLayoutToOrigin(origin, get().spaceLayout, get().folders, currentTs);
}
set(update as any);
@@ -706,16 +736,6 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
}
}
// If the removed origin was the true home, reset the layout authority flag
// so the browsing instance's layout can serve as fallback again
const currentUser = useAuthStore.getState().user;
let resetLayoutFlag = false;
if (currentUser?.homeInstance && origin !== '') {
try {
resetLayoutFlag = new URL(origin).host === currentUser.homeInstance;
} catch { /* ignore */ }
}
return {
spaces: remainingSpaces,
channelToSpaceMap,
@@ -726,7 +746,6 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
currentSpaceId: remainingSpaces.find(s => s.id === state.currentSpaceId)
? state.currentSpaceId
: null,
...(resetLayoutFlag ? { _layoutFromTrueHome: false } : {}),
};
});
},
+121 -40
View File
@@ -1,67 +1,145 @@
import type { UpdateUserRequest } from '@backspace/shared';
import { useAuthStore } from '../stores/authStore';
import { useInstanceStore, type ConnectedInstance } from '../stores/instanceStore';
import { api } from '../api/client';
// ─── Internal helper ────────────────────────────────────────────────────────
async function downloadHomeAsset(filename: string): Promise<Blob> {
async function downloadAsset(filename: string, origin?: string): Promise<Blob> {
if (filename.startsWith('http') || filename.startsWith('blob:')) {
const res = await fetch(filename);
return res.blob();
}
const res = await fetch(`/api/uploads/${filename}`);
const base = origin ? `${origin}/api/uploads/${filename}` : `/api/uploads/${filename}`;
const res = await fetch(base);
return res.blob();
}
// ─── Full profile sync (connect / reconnect) ────────────────────────────────
/**
* Push the entire home profile to a single remote instance.
* Called on initial connect, reconnect, and login-to-remote.
* Bidirectional profile sync with a single remote instance using LWW timestamps.
* - If home is newer (or both equal): push home → remote (existing behavior)
* - If remote is newer: pull remote → home, then relay to all other remotes
* - If equal: no-op
*/
export async function syncProfileToRemote(inst: ConnectedInstance): Promise<void> {
try {
const homeUser = useAuthStore.getState().user;
if (!homeUser) return;
const payload: UpdateUserRequest = {
displayName: homeUser.displayName || undefined,
avatarColor: homeUser.avatarColor || undefined,
accentColor: homeUser.accentColor || undefined,
bio: homeUser.bio || undefined,
customStatus: homeUser.customStatus || undefined,
status: homeUser.status || undefined,
};
const homeTs = homeUser.profileUpdatedAt ?? 0;
const remoteTs = inst.user?.profileUpdatedAt ?? 0;
// Sync avatar
if (homeUser.avatar) {
try {
const blob = await downloadHomeAsset(homeUser.avatar);
const attachment = await inst.api.uploads.upload(new File([blob], homeUser.avatar));
payload.avatar = attachment.filename;
} catch (err) {
console.warn('[ProfileSync] Failed to upload avatar to remote:', err);
}
if (homeTs >= remoteTs) {
// Home is newer (or equal) — push to remote
await pushProfileToRemote(inst, homeUser);
} else {
payload.avatar = '';
// Remote is newer — pull from remote to home, then relay
await pullProfileFromRemote(inst);
}
// Sync banner
if (homeUser.banner) {
try {
const blob = await downloadHomeAsset(homeUser.banner);
const attachment = await inst.api.uploads.upload(new File([blob], homeUser.banner));
payload.banner = attachment.filename;
} catch (err) {
console.warn('[ProfileSync] Failed to upload banner to remote:', err);
}
} else {
payload.banner = '';
}
await inst.api.users.update(payload);
} catch (err) {
console.warn(`[ProfileSync] Full sync to ${inst.origin} failed:`, err);
console.warn(`[ProfileSync] Full sync with ${inst.origin} failed:`, err);
}
}
/**
* Push the home profile to a single remote instance.
*/
async function pushProfileToRemote(inst: ConnectedInstance, homeUser: NonNullable<ReturnType<typeof useAuthStore.getState>['user']>): Promise<void> {
const payload: UpdateUserRequest = {
displayName: homeUser.displayName || undefined,
avatarColor: homeUser.avatarColor || undefined,
accentColor: homeUser.accentColor || undefined,
bio: homeUser.bio || undefined,
customStatus: homeUser.customStatus || undefined,
status: homeUser.status || undefined,
profileUpdatedAt: homeUser.profileUpdatedAt,
};
// Sync avatar
if (homeUser.avatar) {
try {
const blob = await downloadAsset(homeUser.avatar);
const attachment = await inst.api.uploads.upload(new File([blob], homeUser.avatar));
payload.avatar = attachment.filename;
} catch (err) {
console.warn('[ProfileSync] Failed to upload avatar to remote:', err);
}
} else {
payload.avatar = '';
}
// Sync banner
if (homeUser.banner) {
try {
const blob = await downloadAsset(homeUser.banner);
const attachment = await inst.api.uploads.upload(new File([blob], homeUser.banner));
payload.banner = attachment.filename;
} catch (err) {
console.warn('[ProfileSync] Failed to upload banner to remote:', err);
}
} else {
payload.banner = '';
}
await inst.api.users.update(payload);
}
/**
* Pull a remote instance's newer profile to the browsing (home) instance,
* then relay to all other connected remotes.
*/
async function pullProfileFromRemote(inst: ConnectedInstance): Promise<void> {
const remoteUser = inst.user;
if (!remoteUser) return;
const payload: UpdateUserRequest = {
displayName: remoteUser.displayName || undefined,
avatarColor: remoteUser.avatarColor || undefined,
accentColor: remoteUser.accentColor || undefined,
bio: remoteUser.bio || undefined,
customStatus: remoteUser.customStatus || undefined,
profileUpdatedAt: remoteUser.profileUpdatedAt,
};
// Download and re-upload avatar from remote → home
if (remoteUser.avatar) {
try {
const blob = await downloadAsset(remoteUser.avatar, inst.origin);
const attachment = await api.uploads.upload(new File([blob], remoteUser.avatar.split('/').pop() || 'avatar'));
payload.avatar = attachment.filename;
} catch (err) {
console.warn('[ProfileSync] Failed to download/upload avatar from remote:', err);
}
} else {
payload.avatar = '';
}
// Download and re-upload banner from remote → home
if (remoteUser.banner) {
try {
const blob = await downloadAsset(remoteUser.banner, inst.origin);
const attachment = await api.uploads.upload(new File([blob], remoteUser.banner.split('/').pop() || 'banner'));
payload.banner = attachment.filename;
} catch (err) {
console.warn('[ProfileSync] Failed to download/upload banner from remote:', err);
}
} else {
payload.banner = '';
}
// PATCH browsing instance (home) with the remote's newer data
const updatedUser = await api.users.update(payload);
useAuthStore.getState().setUser(updatedUser);
// Relay to all OTHER connected remotes (exclude the source)
const { instances } = useInstanceStore.getState();
const otherConnected = instances.filter(i => i.status === 'connected' && i.origin !== inst.origin);
if (otherConnected.length > 0) {
await Promise.allSettled(
otherConnected.map(other => pushProfileToRemote(other, updatedUser))
);
}
}
@@ -88,6 +166,9 @@ export async function syncProfileUpdateToRemotes(update: Partial<UpdateUserReque
}
}
// Include the LWW timestamp so remotes can reject stale writes
basePayload.profileUpdatedAt = useAuthStore.getState().user?.profileUpdatedAt;
// Pre-download file assets once (if they changed)
let avatarBlob: Blob | null = null;
let avatarFilename: string | null = null;
@@ -97,7 +178,7 @@ export async function syncProfileUpdateToRemotes(update: Partial<UpdateUserReque
if ('avatar' in update) {
if (update.avatar) {
try {
avatarBlob = await downloadHomeAsset(update.avatar);
avatarBlob = await downloadAsset(update.avatar);
avatarFilename = update.avatar;
} catch (err) {
console.warn('[ProfileSync] Failed to download avatar for sync:', err);
@@ -108,7 +189,7 @@ export async function syncProfileUpdateToRemotes(update: Partial<UpdateUserReque
if ('banner' in update) {
if (update.banner) {
try {
bannerBlob = await downloadHomeAsset(update.banner);
bannerBlob = await downloadAsset(update.banner);
bannerFilename = update.banner;
} catch (err) {
console.warn('[ProfileSync] Failed to download banner for sync:', err);