fix(dm): purge DMs with zero live members (exclude-uid, scoped) so dead-both threads clean up (S2)

This commit is contained in:
Jannis Braun
2026-07-02 15:37:33 +02:00
parent e5a1cc9506
commit 12d8256f65
2 changed files with 33 additions and 14 deletions
@@ -56,4 +56,19 @@ describe('tombstoneUser — DM membership partition', () => {
// Survivor's 1-on-1 channel still exists and still has the survivor // Survivor's 1-on-1 channel still exists and still has the survivor
expect(testDb.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, 'dm_1on1')).get()).toBeTruthy(); expect(testDb.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, 'dm_1on1')).get()).toBeTruthy();
}); });
it('keeps a Deleted<->Survivor 1-on-1, purges a Deleted<->Deleted 1-on-1', async () => {
const { tombstoneUser } = await import('./userDeletion.js');
seedUser('alreadyDead', { isDeleted: 1 });
seedUser('victim'); seedUser('survivor');
// Survivor thread — must survive
seedDm('dm_live', null); seedMember('dm_live', 'victim'); seedMember('dm_live', 'survivor');
// Both-dead thread — victim + an already-deleted partner → must be purged
seedDm('dm_dead', null); seedMember('dm_dead', 'victim'); seedMember('dm_dead', 'alreadyDead');
tombstoneUser('victim', { purgeContent: false });
expect(testDb.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, 'dm_live')).get()).toBeTruthy();
expect(testDb.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, 'dm_dead')).get()).toBeUndefined();
});
}); });
+16 -12
View File
@@ -185,20 +185,24 @@ export function tombstoneUser(uid: string, options?: TombstoneOptions): string[]
} }
} }
// Clean up orphaned DM channels (zero members after our removal) — always runs, // Purge DMs that are dead after this deletion: among the channels this user
// orphaned channels are unreachable garbage regardless of purge mode // was in, those with zero LIVE members. The uid being tombstoned right now
const orphanedDmIds = tx.select({ id: schema.dmChannels.id }) // still reads isDeleted=0 (its row is updated below), so it is excluded
.from(schema.dmChannels) // explicitly — this keeps a Deleted<->Survivor 1-on-1 but purges a
.all() // Deleted<->Deleted one. Scoped to the user's channels: only their
.filter(dc => { // membership changed here, so only these can newly become dead.
const memberCount = tx.select({ id: schema.dmMembers.dmChannelId }) const orphanedDmIds = userDmChannelIds.filter(dmId => {
const liveOthers = tx.select({ userId: schema.dmMembers.userId })
.from(schema.dmMembers) .from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, dc.id)) .innerJoin(schema.users, eq(schema.dmMembers.userId, schema.users.id))
.where(and(
eq(schema.dmMembers.dmChannelId, dmId),
eq(schema.users.isDeleted, 0),
))
.all() .all()
.length; .filter(m => m.userId !== uid);
return memberCount === 0; return liveOthers.length === 0;
}) });
.map(dc => dc.id);
for (const dmId of orphanedDmIds) { for (const dmId of orphanedDmIds) {
const msgIds = tx.select({ id: schema.dmMessages.id }) const msgIds = tx.select({ id: schema.dmMessages.id })