feat(federation): add soft-delete GC for empty group DMs with 24h grace period

Replace the hard-delete in the leave handler with a soft-delete (sets
deleted_at timestamp) when the last member leaves a group DM. A new
janitor sweep in the federation worker runs hourly and purges channels
whose grace period has expired, cascading through reactions, embeds,
attachments, messages, members, outbox/mutation-log/file-queue entries,
and finally the channel itself.

All client-facing dm_channels queries now filter on deleted_at IS NULL
to hide soft-deleted channels from the REST API and WebSocket ready
payload.
This commit is contained in:
Jannis Braun
2026-03-26 20:35:55 +01:00
parent 62a16e884a
commit 4efa35f311
4 changed files with 177 additions and 45 deletions
+141 -2
View File
@@ -1,9 +1,9 @@
import fs from 'fs';
import path from 'path';
import { and, eq, isNotNull, lt } from 'drizzle-orm';
import { and, eq, inArray, isNotNull, isNull, lt } from 'drizzle-orm';
import { config } from '../config.js';
import { getDb, getRawDb, schema } from '../db/index.js';
import { deleteUploadFile } from './fileCleanup.js';
import { deleteUploadFile, deleteAttachmentFiles } from './fileCleanup.js';
import type { StorageStats, StorageBreakdown, OrphanedFile, CleanupResult } from '@backspace/shared';
const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.ico', '.bmp', '.avif']);
@@ -430,3 +430,142 @@ export function cleanupFederationFileQueue(): number {
return completed.changes + expired.changes;
}
/**
* Hard-delete DM channels that were soft-deleted more than 24 hours ago.
* Cascades: reactions, embeds, attachments (files + DB rows), messages,
* members, outbox entries, mutation log entries, file queue entries, then the channel.
* Returns the number of channels purged.
*/
export function cleanupSoftDeletedDmChannels(): number {
const db = getDb();
const gracePeriodMs = 24 * 60 * 60 * 1000; // 24 hours
const cutoff = Date.now() - gracePeriodMs;
const expired = db
.select({ id: schema.dmChannels.id })
.from(schema.dmChannels)
.where(
and(
isNotNull(schema.dmChannels.deletedAt),
lt(schema.dmChannels.deletedAt, cutoff),
),
)
.all();
if (expired.length === 0) return 0;
let purged = 0;
for (const channel of expired) {
try {
// Get message IDs and attachment filenames before deletion
const msgIds = db.select({ id: schema.dmMessages.id })
.from(schema.dmMessages)
.where(eq(schema.dmMessages.dmChannelId, channel.id))
.all()
.map(m => m.id);
const filesToDelete: string[] = [];
if (msgIds.length > 0) {
const attachments = db.select({ filename: schema.attachments.filename })
.from(schema.attachments)
.where(inArray(schema.attachments.dmMessageId, msgIds))
.all();
filesToDelete.push(...attachments.map(a => a.filename));
}
db.transaction((tx) => {
if (msgIds.length > 0) {
// Delete reactions
tx.delete(schema.dmReactions)
.where(inArray(schema.dmReactions.dmMessageId, msgIds))
.run();
// Delete embeds
tx.delete(schema.embeds)
.where(inArray(schema.embeds.dmMessageId, msgIds))
.run();
// Delete attachments (DB rows)
tx.delete(schema.attachments)
.where(inArray(schema.attachments.dmMessageId, msgIds))
.run();
// Delete file queue entries
tx.delete(schema.federationFileQueue)
.where(inArray(schema.federationFileQueue.dmMessageId, msgIds))
.run();
}
// Delete messages
tx.delete(schema.dmMessages)
.where(eq(schema.dmMessages.dmChannelId, channel.id))
.run();
// Delete members (should be 0, defensive)
tx.delete(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, channel.id))
.run();
// Delete read states
tx.delete(schema.readStates)
.where(eq(schema.readStates.channelId, channel.id))
.run();
// Delete federation outbox entries
tx.delete(schema.federationOutbox)
.where(eq(schema.federationOutbox.dmChannelId, channel.id))
.run();
// Delete mutation log entries
tx.delete(schema.federationMutationLog)
.where(eq(schema.federationMutationLog.dmChannelId, channel.id))
.run();
// Delete the channel itself
tx.delete(schema.dmChannels)
.where(eq(schema.dmChannels.id, channel.id))
.run();
});
// Clean up files from disk (outside transaction — filesystem ops are idempotent)
deleteAttachmentFiles(filesToDelete.map(f => ({ filename: f })));
purged++;
} catch (err) {
console.error(`[storage-janitor] Failed to purge soft-deleted DM channel ${channel.id}:`, err);
}
}
if (purged > 0) {
console.log(`[storage-janitor] Purged ${purged} soft-deleted DM channels`);
}
return purged;
}
/**
* Run all periodic federation/GC cleanup tasks:
* - Expired outbox entries
* - Old mutation log entries
* - Stale file queue entries
* - Soft-deleted DM channels past grace period
*/
export function runFederationJanitor(): void {
try {
const outbox = cleanupFederationOutbox();
const mutLog = cleanupFederationMutationLog();
const fileQ = cleanupFederationFileQueue();
const dmGc = cleanupSoftDeletedDmChannels();
const total = outbox + mutLog + fileQ + dmGc;
if (total > 0) {
console.log(
`[storage-janitor] Federation GC sweep: outbox=${outbox} mutationLog=${mutLog} fileQueue=${fileQ} dmChannels=${dmGc}`,
);
}
} catch (err) {
console.error('[storage-janitor] Federation GC sweep error:', err);
}
}