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
+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) => {