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.
This commit is contained in:
Marco Beretta 2026-07-05 00:12:16 +02:00
parent e43364982d
commit b871ae4b5e
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
2 changed files with 35 additions and 1 deletions

View file

@ -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<IMessage>;
const File = () => mongoose.models.File as mongoose.Model<IMongoFile>;
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<IMongoFile>();
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);

View file

@ -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<IMessage>;
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
const File = mongoose.models.File as Model<IMongoFile>;
await forceConversationMessagesTemporary(Message, userId, conversationId, forcedExpiredAt);
await capConversationSharedLinks(SharedLink, userId, conversationId, forcedExpiredAt);
await capConversationFiles(File, conversationId, forcedExpiredAt);
}
if (