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
+19
View File
@@ -117,6 +117,12 @@ export function runMigrations(db: Database.Database): void {
columns: [
{ name: 'category_id', type: 'TEXT' },
]
},
{
name: 'users',
columns: [
{ name: 'profile_updated_at', type: 'INTEGER' },
]
}
];
@@ -233,6 +239,9 @@ export function runMigrations(db: Database.Database): void {
// ─── Convert video channels to voice (video type removed) ─────────────────
migrateVideoChannels(db);
// ─── Backfill profile_updated_at from created_at ──────────────────────────
migrateProfileUpdatedAt(db);
// ─── Ensure user_space_layout table exists ────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS user_space_layout (
@@ -651,6 +660,16 @@ function migrateVideoChannels(db: Database.Database): void {
}
}
/** Backfill profile_updated_at from created_at for existing users */
function migrateProfileUpdatedAt(db: Database.Database): void {
const result = db.prepare(
'UPDATE users SET profile_updated_at = created_at WHERE profile_updated_at IS NULL'
).run();
if (result.changes > 0) {
console.log(`Migrating: Backfilled profile_updated_at for ${result.changes} user(s)`);
}
}
function migrateReplicatedUsernames(db: Database.Database): void {
const rows = db.prepare(
"SELECT id, username, home_instance FROM users WHERE home_instance IS NOT NULL AND username NOT LIKE '%@%'"
+1
View File
@@ -17,6 +17,7 @@ export const users = sqliteTable('users', {
avatarColor: text('avatar_color'),
bio: text('bio'),
isDeleted: integer('is_deleted').default(0),
profileUpdatedAt: integer('profile_updated_at'),
createdAt: integer('created_at').notNull(),
});
+65 -10
View File
@@ -255,7 +255,7 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
});
app.patch<{ Body: UpdateUserRequest }>('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
const { displayName, avatar, banner, accentColor, avatarColor, bio, customStatus, status, replicatedInstances, homeUserId } = request.body;
const { displayName, avatar, banner, accentColor, avatarColor, bio, customStatus, status, replicatedInstances, homeUserId, profileUpdatedAt } = request.body;
const db = getDb();
const updateData: Record<string, string | null | undefined> = {};
@@ -370,6 +370,28 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
}
// LWW guard: if the caller provided a profileUpdatedAt and profile fields changed,
// reject stale writes by comparing timestamps
const profileFields = ['displayName', 'avatar', 'banner', 'accentColor', 'avatarColor', 'bio', 'customStatus'];
const hasProfileChange = profileFields.some(f => f in updateData);
if (hasProfileChange) {
if (profileUpdatedAt !== undefined && typeof profileUpdatedAt === 'number') {
const currentUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
if (currentUser) {
const storedTs = currentUser.profileUpdatedAt ?? currentUser.createdAt;
if (profileUpdatedAt < storedTs) {
// Incoming data is older — return current state without updating
return reply.code(200).send(sanitizeUser(currentUser));
}
}
(updateData as Record<string, unknown>).profileUpdatedAt = profileUpdatedAt;
} else {
// No explicit timestamp — stamp with server time (local edits)
(updateData as Record<string, unknown>).profileUpdatedAt = Date.now();
}
}
db.update(schema.users).set(updateData).where(eq(schema.users.id, request.userId)).run();
const updatedUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
@@ -396,10 +418,7 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
});
}
// Broadcast user_updated for profile field changes
const profileFields = ['displayName', 'avatar', 'banner', 'accentColor', 'avatarColor', 'bio', 'customStatus'];
const hasProfileChange = profileFields.some(f => f in updateData);
// Broadcast user_updated for profile field changes (reuse hasProfileChange from LWW guard above)
if (hasProfileChange) {
const userUpdatedEvent = { type: 'user_updated' as const, user: sanitized };
const targetUserIds = new Set<string>();
@@ -444,9 +463,9 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
});
// PUT /api/users/@me/space-layout — save sidebar layout (reorder, folders)
app.put<{ Body: { items: SpaceLayoutItem[]; folders: Record<string, { name: string | null; color: string | null; spaceIds: string[] }> } }>(
app.put<{ Body: { items: SpaceLayoutItem[]; folders: Record<string, { name: string | null; color: string | null; spaceIds: string[] }>; updatedAt?: number } }>(
'/api/users/@me/space-layout', { preHandler: authenticate }, async (request, reply) => {
const { items, folders } = request.body;
const { items, folders, updatedAt: incomingTs } = request.body;
const userId = request.userId;
if (!Array.isArray(items)) {
@@ -472,6 +491,39 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
const db = getDb();
// LWW guard: reject stale layout writes
if (incomingTs !== undefined && typeof incomingTs === 'number') {
const existingLayout = db.select().from(schema.userSpaceLayout)
.where(eq(schema.userSpaceLayout.userId, userId)).get();
if (existingLayout && incomingTs < existingLayout.updatedAt) {
// Incoming layout is older — return current state without updating
const currentItems: SpaceLayoutItem[] = JSON.parse(existingLayout.layout);
const currentFolderRows = db.select().from(schema.spaceFolders)
.where(eq(schema.spaceFolders.userId, userId))
.orderBy(schema.spaceFolders.position)
.all();
const currentFolders: SpaceFolder[] = currentFolderRows.map(folder => {
const memberRows = db.select()
.from(schema.spaceFolderMembers)
.where(eq(schema.spaceFolderMembers.folderId, folder.id))
.orderBy(schema.spaceFolderMembers.position)
.all();
return {
id: folder.id,
userId: folder.userId,
name: folder.name,
color: folder.color,
position: folder.position ?? 0,
spaceIds: memberRows.map(m => m.spaceId),
};
});
return reply.code(200).send({ items: currentItems, folders: currentFolders, updatedAt: existingLayout.updatedAt });
}
}
// Resolve the effective timestamp for this write
const effectiveTs = (incomingTs !== undefined && typeof incomingTs === 'number') ? incomingTs : Date.now();
// Map new:* folder keys to server-generated IDs
const newIdMap = new Map<string, string>();
for (const key of Object.keys(folders)) {
@@ -553,14 +605,14 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
.where(eq(schema.userSpaceLayout.userId, userId)).get();
if (existing) {
tx.update(schema.userSpaceLayout)
.set({ layout: JSON.stringify(finalItems), updatedAt: Date.now() })
.set({ layout: JSON.stringify(finalItems), updatedAt: effectiveTs })
.where(eq(schema.userSpaceLayout.userId, userId))
.run();
} else {
tx.insert(schema.userSpaceLayout).values({
userId,
layout: JSON.stringify(finalItems),
updatedAt: Date.now(),
updatedAt: effectiveTs,
}).run();
}
});
@@ -592,14 +644,17 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
});
}
const layoutUpdatedAt = finalLayout?.updatedAt ?? effectiveTs;
// Broadcast to user's other connections (multi-tab sync)
connectionManager.sendToUser(userId, {
type: 'space_layout_updated',
layout: finalItems,
folders: responseFolders,
updatedAt: layoutUpdatedAt,
});
return reply.code(200).send({ items: finalItems, folders: responseFolders });
return reply.code(200).send({ items: finalItems, folders: responseFolders, updatedAt: layoutUpdatedAt });
});
app.get<{ Params: { id: string } }>('/api/users/:id', { preHandler: authenticate }, async (request, reply) => {
+2
View File
@@ -17,6 +17,7 @@ export function sanitizeUser(row: typeof schema.users.$inferSelect): User {
customStatus: null,
isAdmin: false,
isDeleted: true,
profileUpdatedAt: 0,
createdAt: row.createdAt,
homeInstance: null,
homeUserId: null,
@@ -45,6 +46,7 @@ export function sanitizeUser(row: typeof schema.users.$inferSelect): User {
status: (row.status ?? 'offline') as User['status'],
customStatus: row.customStatus,
isAdmin: row.isAdmin === 1,
profileUpdatedAt: row.profileUpdatedAt ?? row.createdAt,
createdAt: row.createdAt,
homeInstance: row.homeInstance ?? null,
homeUserId: row.homeUserId ?? null,
+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> {