feat(federation): re-attach reconciles the account's 1-on-1 DM federatedIds inline (reattach-dm-reconcile spec §3.2)

This commit is contained in:
Jannis Braun
2026-07-03 12:34:18 +02:00
parent 1da44c8d6b
commit 0bb4ef8cad
2 changed files with 95 additions and 0 deletions
+41
View File
@@ -2948,6 +2948,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
// rows that would collide on a composite PK / unique index BEFORE repointing
// (spec §3.3). The stub row is the only source — a real account holding the
// identity was already rejected by guard 4.
const dmReconcileResults: DmReconcileResult[] = [];
rawDb.transaction(() => {
if (existingRow) {
const stubId = existingRow.id;
@@ -3001,6 +3002,25 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
// only fire on federation_home_orphaned = 1).
rawDb.prepare(`UPDATE users SET home_user_id = ?, federation_home_orphaned = 0, username = ?, profile_updated_at = NULL WHERE id = ?`)
.run(verified.homeUserId, newUsername, detached.id);
// Reconcile the account's 1-on-1 DM channels: the home_user_id just
// changed, so every 1-on-1 federatedId derived from it is now stale.
// Re-key or merge each into its new-identity channel so history stays a
// single conversation (reattach-dm-reconcile spec §3.2). Group DMs (UUID
// federatedId / != 2 members) are skipped by the helper.
const oneOnOne = rawDb.prepare(`
SELECT c.id FROM dm_channels c
WHERE c.deleted_at IS NULL
AND c.federated_id IS NOT NULL
AND EXISTS (SELECT 1 FROM dm_members m WHERE m.dm_channel_id = c.id AND m.user_id = ?)
AND (SELECT count(*) FROM dm_members m2 WHERE m2.dm_channel_id = c.id) = 2
`).all(detached.id) as Array<{ id: string }>;
for (const c of oneOnOne) {
// A merge earlier in this loop may have deleted this id — reconcile
// returns noop for a missing/mutated channel, so the loop is convergent.
const result = reconcileDmChannelFederatedId(rawDb, c.id);
if (result.action !== 'noop') dmReconcileResults.push(result);
}
})();
// Best-effort initial profile pull (spec §3.2 step 4). Failure is fine — the
@@ -3036,6 +3056,27 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
connectionManager.sendToUser(uid, { type: 'user_updated' as const, user: sanitizeUser(updated, uid === updated.id) });
}
// Push DM-list refresh for reconciled channels to affected local members so
// the merged/re-keyed conversation replaces the split without a reload
// (reattach-dm-reconcile spec §3.4). Reuses existing events, no new type:
// - merged: dm_channel_closed removes the stale source entry; dm_channel_created
// (full DmChannel payload — the client handler reads dmChannel.members) resurfaces
// the surviving target with its merged history.
// - rekeyed: dm_channel_created upserts the channel by id (spaceStore.addDmChannel
// replaces by id), refreshing the now-stale federatedId in place. dm_channel_updated
// would only patch name/icon, not federatedId, so it cannot heal the client here.
for (const r of dmReconcileResults) {
const targetPayload = buildDmChannelPayload(r.targetChannelId, db);
for (const uid of r.affectedUserIds) {
if (r.action === 'merged') {
connectionManager.sendToUser(uid, { type: 'dm_channel_closed' as const, dmChannelId: r.channelId });
}
if (targetPayload) {
connectionManager.sendToUser(uid, { type: 'dm_channel_created' as const, dmChannel: targetPayload });
}
}
}
return reply.code(200).send({ success: true, user: sanitizeUser(updated, true) });
});