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
+3 -1
View File
@@ -654,6 +654,7 @@ function buildReadyPayload(userId: string): {
dmChannels: DmChannel[];
folders: SpaceFolder[];
spaceLayout: SpaceLayoutItem[] | null;
layoutUpdatedAt: number | null;
voiceStates: Record<string, string[]>;
voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }>;
@@ -946,6 +947,7 @@ function buildReadyPayload(userId: string): {
const layoutRow = db.select().from(schema.userSpaceLayout)
.where(eq(schema.userSpaceLayout.userId, userId)).get();
const spaceLayout: SpaceLayoutItem[] | null = layoutRow ? JSON.parse(layoutRow.layout) : null;
const layoutUpdatedAt: number | null = layoutRow?.updatedAt ?? null;
// Build voice states — tell the client who is currently in voice channels
// across all their spaces
@@ -1037,7 +1039,7 @@ function buildReadyPayload(userId: string): {
lastReadMessageId: rs.lastReadMessageId,
}));
return { user, spaces, dmChannels, folders, spaceLayout, voiceStates, voiceUserStates, spaceVoiceStates, readStates, activeCalls };
return { user, spaces, dmChannels, folders, spaceLayout, layoutUpdatedAt, voiceStates, voiceUserStates, spaceVoiceStates, readStates, activeCalls };
}
export async function registerWebSocket(app: FastifyInstance): Promise<void> {