fix: repair corrupted group DMs, prevent ownerId nulling, show empty groups

Three fixes for group DM data integrity and display:

1. processOwnershipTransferEvent: use resolveOrCreateReplicatedUser instead
   of resolveLocalUser to guarantee a valid ownerId. The previous ?? null
   fallback converted group DMs into 1-on-1s when resolution failed.

2. Self-healing migration: detect group DMs with UUID-format federated_id
   but NULL owner_id (corrupted by the old fallback) and restore owner from
   the first remaining member. Found and repaired 7 across both instances.

3. Sidebar: group DMs with 0 other members (last person standing) now show
   as "Empty Group" instead of being hidden. 1-on-1 DMs with 0 others are
   still correctly filtered out.
This commit is contained in:
Jannis Braun
2026-03-27 17:38:09 +01:00
parent 44b6317c16
commit 41658ec2ef
4 changed files with 51 additions and 10 deletions
+31
View File
@@ -635,6 +635,37 @@ export function runMigrations(db: Database.Database): void {
db.exec(`ALTER TABLE federation_peers ADD COLUMN remote_max_upload_size INTEGER`);
} catch { /* column already exists */ }
// ─── Data integrity: repair group DMs with nulled-out owner_id ───────────
// A bug in processOwnershipTransferEvent (fixed in cd7aff0) could set
// owner_id to NULL when resolveLocalUser failed, converting a group DM into
// a 1-on-1-looking channel. Detect these by finding dm_channels with a
// UUID-format federated_id (group DMs) but NULL owner_id, and restore the
// owner from the first remaining member.
const corruptedGroups = db.prepare(`
SELECT c.id, c.federated_id
FROM dm_channels c
WHERE c.owner_id IS NULL
AND c.federated_id IS NOT NULL
AND c.deleted_at IS NULL
AND length(c.federated_id) = 36
AND c.federated_id LIKE '________-____-____-____-____________'
`).all() as Array<{ id: string; federated_id: string }>;
for (const ch of corruptedGroups) {
const firstMember = db.prepare(
`SELECT user_id FROM dm_members WHERE dm_channel_id = ? LIMIT 1`
).get(ch.id) as { user_id: string } | undefined;
if (firstMember) {
db.prepare(`UPDATE dm_channels SET owner_id = ? WHERE id = ?`).run(firstMember.user_id, ch.id);
console.log(`[migration] Repaired group DM ${ch.id}: restored owner_id to ${firstMember.user_id}`);
}
}
if (corruptedGroups.length > 0) {
console.log(`[migration] Repaired ${corruptedGroups.length} corrupted group DM(s).`);
}
console.log('Migrations complete.');
}
+12 -6
View File
@@ -1967,12 +1967,18 @@ function processOwnershipTransferEvent(
return;
}
// Resolve new owner to local user (for local ownerId)
const newOwnerLocal = resolveLocalUser(event.ownership.newOwner.homeUserId, db);
// Resolve new owner to local user — use resolveOrCreateReplicatedUser to
// guarantee we always get a valid user ID. Never fall back to null, as that
// would convert the group DM into a 1-on-1 and destroy its type identity.
const newOwnerLocal = resolveOrCreateReplicatedUser(
event.ownership.newOwner.homeUserId,
event.ownership.newOwner.homeInstance,
db,
);
db.update(schema.dmChannels)
.set({
ownerId: newOwnerLocal?.id ?? null,
ownerId: newOwnerLocal.id,
ownerHomeUserId: event.ownership.newOwner.homeUserId,
ownerHomeInstance: event.ownership.newOwner.homeInstance,
})
@@ -1982,7 +1988,7 @@ function processOwnershipTransferEvent(
connectionManager.sendToDmMembers(channel.id, {
type: 'dm_owner_updated',
dmChannelId: channel.id,
newOwnerId: newOwnerLocal?.id ?? event.ownership.newOwner.homeUserId,
newOwnerId: newOwnerLocal.id,
});
const prevOwnerLocal = event.ownership.previousOwner
@@ -1998,8 +2004,8 @@ function processOwnershipTransferEvent(
userId: prevOwnerId,
content: JSON.stringify({
event: 'owner_changed',
newOwnerId: newOwnerLocal?.id ?? event.ownership.newOwner.homeUserId,
newOwnerDisplayName: newOwnerLocal?.displayName ?? newOwnerBaseName,
newOwnerId: newOwnerLocal.id,
newOwnerDisplayName: newOwnerLocal.displayName ?? newOwnerBaseName,
}),
type: 'system',
createdAt: Date.now(),