From b871ae4b5e3dafff5d10f3fa2c1df350002cf3a9 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:12:16 +0200 Subject: [PATCH] fix: cap uploaded files when saveConvo forces ephemeral retention saveConvo's inline forced-retention backfill converted the conversation and capped its messages and shared links but left File records at expiredAt: null, so archive/pin/title updates on a pre-existing chat under ephemeral mode left its uploads in storage after the conversation and messages TTL out. File cleanup only sweeps rows whose own expiredAt is set. Cap the conversation's files alongside messages and shares, matching the other forced-retention cascades. --- .../src/methods/conversation.spec.ts | 32 ++++++++++++++++++- .../data-schemas/src/methods/conversation.ts | 4 +++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/data-schemas/src/methods/conversation.spec.ts b/packages/data-schemas/src/methods/conversation.spec.ts index ac4af0b199..ce00cc5104 100644 --- a/packages/data-schemas/src/methods/conversation.spec.ts +++ b/packages/data-schemas/src/methods/conversation.spec.ts @@ -2,7 +2,7 @@ import mongoose from 'mongoose'; import { v4 as uuidv4 } from 'uuid'; import { MongoMemoryServer } from 'mongodb-memory-server'; import { EModelEndpoint, RetentionMode } from 'librechat-data-provider'; -import type { IChatProject, IConversation, IMessage, ISharedLink } from '../types'; +import type { IChatProject, IConversation, IMessage, IMongoFile, ISharedLink } from '../types'; import { ConversationMethods, createConversationMethods } from './conversation'; import { tenantStorage, runAsSystem } from '~/config/tenantContext'; import { createModels } from '../models'; @@ -851,9 +851,11 @@ describe('Conversation Operations', () => { describe('forced retention message cascade', () => { const Message = () => mongoose.models.Message as mongoose.Model; + const File = () => mongoose.models.File as mongoose.Model; beforeEach(async () => { await Message().deleteMany({}); + await File().deleteMany({}); }); it('backfills existing messages when ephemeral converts a permanent conversation', async () => { @@ -889,6 +891,34 @@ describe('Conversation Operations', () => { } }); + it('caps existing files when ephemeral converts a permanent conversation', async () => { + const conversationId = uuidv4(); + const fileId = uuidv4(); + await Conversation.create({ + conversationId, + user: 'user123', + endpoint: EModelEndpoint.openAI, + title: 'Existing permanent chat', + }); + await File().collection.insertOne({ + file_id: fileId, + conversationId, + user: new mongoose.Types.ObjectId(), + expiredAt: null, + }); + + await saveConvo( + { + userId: 'user123', + interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, + }, + { conversationId, isArchived: true }, + ); + + const file = await File().findOne({ file_id: fileId }).lean(); + expect(file?.expiredAt).toBeInstanceOf(Date); + }); + it('converts an active retained (all-mode) conversation when switching to ephemeral', async () => { const conversationId = uuidv4(); const retainedUntil = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000); diff --git a/packages/data-schemas/src/methods/conversation.ts b/packages/data-schemas/src/methods/conversation.ts index 481097a289..246850198f 100644 --- a/packages/data-schemas/src/methods/conversation.ts +++ b/packages/data-schemas/src/methods/conversation.ts @@ -6,11 +6,13 @@ import type { IChatProjectDocument, IConversation, IMessage, + IMongoFile, ISharedLink, } from '~/types'; import type { MessageMethods } from './message'; import { buildRetentionVisibilityFilter, + capConversationFiles, capConversationSharedLinks, capForcedRetentionExpiry, conversationNeedsForcedRetention, @@ -365,8 +367,10 @@ export function createConversationMethods( ) { const Message = mongoose.models.Message as Model; const SharedLink = mongoose.models.SharedLink as Model; + const File = mongoose.models.File as Model; await forceConversationMessagesTemporary(Message, userId, conversationId, forcedExpiredAt); await capConversationSharedLinks(SharedLink, userId, conversationId, forcedExpiredAt); + await capConversationFiles(File, conversationId, forcedExpiredAt); } if (