diff --git a/packages/server/src/utils/userDeletion.dmMembership.test.ts b/packages/server/src/utils/userDeletion.dmMembership.test.ts index 1d5a01d6..9de70378 100644 --- a/packages/server/src/utils/userDeletion.dmMembership.test.ts +++ b/packages/server/src/utils/userDeletion.dmMembership.test.ts @@ -56,4 +56,19 @@ describe('tombstoneUser — DM membership partition', () => { // 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(); }); + + 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(); + }); }); diff --git a/packages/server/src/utils/userDeletion.ts b/packages/server/src/utils/userDeletion.ts index 82811e66..3d050a7b 100644 --- a/packages/server/src/utils/userDeletion.ts +++ b/packages/server/src/utils/userDeletion.ts @@ -185,20 +185,24 @@ export function tombstoneUser(uid: string, options?: TombstoneOptions): string[] } } - // Clean up orphaned DM channels (zero members after our removal) — always runs, - // orphaned channels are unreachable garbage regardless of purge mode - const orphanedDmIds = tx.select({ id: schema.dmChannels.id }) - .from(schema.dmChannels) - .all() - .filter(dc => { - const memberCount = tx.select({ id: schema.dmMembers.dmChannelId }) - .from(schema.dmMembers) - .where(eq(schema.dmMembers.dmChannelId, dc.id)) - .all() - .length; - return memberCount === 0; - }) - .map(dc => dc.id); + // Purge DMs that are dead after this deletion: among the channels this user + // was in, those with zero LIVE members. The uid being tombstoned right now + // still reads isDeleted=0 (its row is updated below), so it is excluded + // explicitly — this keeps a Deleted<->Survivor 1-on-1 but purges a + // Deleted<->Deleted one. Scoped to the user's channels: only their + // membership changed here, so only these can newly become dead. + const orphanedDmIds = userDmChannelIds.filter(dmId => { + const liveOthers = tx.select({ userId: schema.dmMembers.userId }) + .from(schema.dmMembers) + .innerJoin(schema.users, eq(schema.dmMembers.userId, schema.users.id)) + .where(and( + eq(schema.dmMembers.dmChannelId, dmId), + eq(schema.users.isDeleted, 0), + )) + .all() + .filter(m => m.userId !== uid); + return liveOthers.length === 0; + }); for (const dmId of orphanedDmIds) { const msgIds = tx.select({ id: schema.dmMessages.id })