/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import type {ChannelID, MessageID, UserID} from '~/BrandedTypes'; import {BatchBuilder, Db, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra'; import type {ChannelMessageBucketRow, ChannelStateRow} from '~/database/CassandraTypes'; import type {Message} from '~/Models'; import { AttachmentLookup, ChannelEmptyBuckets, ChannelMessageBuckets, ChannelPins, ChannelState, MessageReactions, Messages, MessagesByAuthor, } from '~/Tables'; import * as BucketUtils from '~/utils/BucketUtils'; import type {MessageDataRepository} from './MessageDataRepository'; const BULK_DELETE_BATCH_SIZE = 100; const POST_DELETE_BUCKET_CHECK_LIMIT = 25; const HAS_ANY_MESSAGE_IN_BUCKET = Messages.select({ columns: ['message_id'], where: [Messages.where.eq('channel_id'), Messages.where.eq('bucket')], limit: 1, }); const FETCH_CHANNEL_STATE = ChannelState.select({ where: ChannelState.where.eq('channel_id'), limit: 1, }); const LIST_BUCKETS_DESC = ChannelMessageBuckets.select({ columns: ['bucket'], where: ChannelMessageBuckets.where.eq('channel_id'), orderBy: {col: 'bucket', direction: 'DESC'}, limit: POST_DELETE_BUCKET_CHECK_LIMIT, }); const FETCH_LATEST_MESSAGE_ID_IN_BUCKET = Messages.select({ columns: ['message_id'], where: [Messages.where.eq('channel_id'), Messages.where.eq('bucket')], limit: 1, }); export class MessageDeletionRepository { constructor(private messageDataRepo: MessageDataRepository) {} private addMessageDeletionBatchQueries( batch: BatchBuilder, channelId: ChannelID, messageId: MessageID, bucket: number, message: Message | null, authorId?: UserID, pinnedTimestamp?: Date, ): void { batch.addPrepared( Messages.deleteByPk({ channel_id: channelId, bucket, message_id: messageId, }), ); const effectiveAuthorId = authorId ?? message?.authorId ?? null; if (effectiveAuthorId) { batch.addPrepared( MessagesByAuthor.deleteByPk({ author_id: effectiveAuthorId, channel_id: channelId, message_id: messageId, }), ); } const effectivePinned = pinnedTimestamp ?? message?.pinnedTimestamp ?? null; if (effectivePinned) { batch.addPrepared( ChannelPins.deleteByPk({ channel_id: channelId, message_id: messageId, pinned_timestamp: effectivePinned, }), ); } batch.addPrepared( MessageReactions.deletePartition({ channel_id: channelId, bucket, message_id: messageId, }), ); if (message?.attachments) { for (const attachment of message.attachments) { batch.addPrepared( AttachmentLookup.deleteByPk({ channel_id: channelId, attachment_id: attachment.id, filename: attachment.filename, }), ); } } } private async markBucketEmpty(channelId: ChannelID, bucket: number): Promise { const batch = new BatchBuilder(); batch.addPrepared( ChannelMessageBuckets.deleteByPk({ channel_id: channelId, bucket, }), ); batch.addPrepared( ChannelEmptyBuckets.upsertAll({ channel_id: channelId, bucket, updated_at: new Date(), }), ); await batch.execute(true); } private async isBucketEmpty(channelId: ChannelID, bucket: number): Promise { const row = await fetchOne<{message_id: bigint}>( HAS_ANY_MESSAGE_IN_BUCKET.bind({ channel_id: channelId, bucket, }), ); return row == null; } private async reconcileChannelStateIfNeeded( channelId: ChannelID, deletedMessageIds: Array, emptiedBuckets: Set, ): Promise { const state = await fetchOne(FETCH_CHANNEL_STATE.bind({channel_id: channelId})); if (!state) return; const lastBucket = state.last_message_bucket as number | null | undefined; const lastId = state.last_message_id as MessageID | null | undefined; const touchedLast = (lastBucket != null && emptiedBuckets.has(lastBucket)) || (lastId != null && deletedMessageIds.includes(lastId)); if (!touchedLast) return; const bucketRows = await fetchMany>( LIST_BUCKETS_DESC.bind({channel_id: channelId}), ); for (const {bucket} of bucketRows) { const latest = await fetchOne<{message_id: bigint}>( FETCH_LATEST_MESSAGE_ID_IN_BUCKET.bind({channel_id: channelId, bucket}), ); if (!latest) { await this.markBucketEmpty(channelId, bucket); continue; } await upsertOne( ChannelState.patchByPk( {channel_id: channelId}, { has_messages: Db.set(true), last_message_bucket: Db.set(bucket), last_message_id: Db.set(latest.message_id as MessageID), updated_at: Db.set(new Date()), }, ), ); return; } await upsertOne( ChannelState.patchByPk( {channel_id: channelId}, { has_messages: Db.set(false), last_message_bucket: Db.clear(), last_message_id: Db.clear(), updated_at: Db.set(new Date()), }, ), ); } private async postDeleteMaintenance( channelId: ChannelID, affectedBuckets: Set, deletedMessageIds: Array, ): Promise { const emptiedBuckets = new Set(); for (const bucket of affectedBuckets) { const empty = await this.isBucketEmpty(channelId, bucket); if (!empty) continue; emptiedBuckets.add(bucket); await this.markBucketEmpty(channelId, bucket); } if (emptiedBuckets.size > 0 || deletedMessageIds.length > 0) { await this.reconcileChannelStateIfNeeded(channelId, deletedMessageIds, emptiedBuckets); } } async deleteMessage( channelId: ChannelID, messageId: MessageID, authorId: UserID, pinnedTimestamp?: Date, ): Promise { const bucket = BucketUtils.makeBucket(messageId); const message = await this.messageDataRepo.getMessage(channelId, messageId); const batch = new BatchBuilder(); this.addMessageDeletionBatchQueries(batch, channelId, messageId, bucket, message, authorId, pinnedTimestamp); await batch.execute(); await this.postDeleteMaintenance(channelId, new Set([bucket]), [messageId]); } async bulkDeleteMessages(channelId: ChannelID, messageIds: Array): Promise { if (messageIds.length === 0) return; for (let i = 0; i < messageIds.length; i += BULK_DELETE_BATCH_SIZE) { const chunk = messageIds.slice(i, i + BULK_DELETE_BATCH_SIZE); const messages = await Promise.all(chunk.map((id) => this.messageDataRepo.getMessage(channelId, id))); const affectedBuckets = new Set(); const batch = new BatchBuilder(); for (let j = 0; j < chunk.length; j++) { const messageId = chunk[j]; const message = messages[j]; const bucket = BucketUtils.makeBucket(messageId); affectedBuckets.add(bucket); this.addMessageDeletionBatchQueries(batch, channelId, messageId, bucket, message); } await batch.execute(); await this.postDeleteMaintenance(channelId, affectedBuckets, chunk); } } async deleteAllChannelMessages(channelId: ChannelID): Promise { const BATCH_SIZE = 50; let hasMore = true; let beforeMessageId: MessageID | undefined; const allDeleted: Array = []; const affectedBuckets = new Set(); while (hasMore) { const messages = await this.messageDataRepo.listMessages(channelId, beforeMessageId, 100); if (messages.length === 0) { hasMore = false; break; } for (let i = 0; i < messages.length; i += BATCH_SIZE) { const batch = new BatchBuilder(); const messageBatch = messages.slice(i, i + BATCH_SIZE); for (const message of messageBatch) { const bucket = BucketUtils.makeBucket(message.id); affectedBuckets.add(bucket); allDeleted.push(message.id); this.addMessageDeletionBatchQueries( batch, channelId, message.id, bucket, message, message.authorId ?? undefined, message.pinnedTimestamp || undefined, ); } await batch.execute(); } if (messages.length < 100) { hasMore = false; } else { beforeMessageId = messages[messages.length - 1].id; } } await this.postDeleteMaintenance(channelId, affectedBuckets, allDeleted); await deleteOneOrMany( ChannelMessageBuckets.deletePartition({ channel_id: channelId, }), ); await deleteOneOrMany( ChannelEmptyBuckets.deletePartition({ channel_id: channelId, }), ); } }