diff --git a/packages/data-schemas/src/methods/message.spec.ts b/packages/data-schemas/src/methods/message.spec.ts index c38c1c8c5a..61f58d90a8 100644 --- a/packages/data-schemas/src/methods/message.spec.ts +++ b/packages/data-schemas/src/methods/message.spec.ts @@ -902,6 +902,39 @@ describe('Message Operations', () => { expect(saved?.expiredAt?.getTime()).toBeLessThanOrEqual(convo?.expiredAt?.getTime() ?? 0); }); + it('backfills lagging messages when the parent already expires sooner', async () => { + const conversationId = uuidv4(); + const parentExpiry = new Date(Date.now() + 60 * 60 * 1000); + await Conversation().create({ + conversationId, + user: 'user123', + endpoint: 'openAI', + title: 'Ephemeral chat expiring soon', + isTemporary: true, + expiredAt: parentExpiry, + }); + const laggingMessageId = uuidv4(); + await Message.create({ + messageId: laggingMessageId, + conversationId, + user: 'user123', + text: 'older message with no expiry', + }); + + await saveMessage( + { + userId: 'user123', + interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, + }, + { messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' }, + { context: 'branch', capExpiryToConversation: true }, + ); + + const lagging = await Message.findOne({ messageId: laggingMessageId }).lean(); + expect(lagging?.isTemporary).toBe(true); + expect(lagging?.expiredAt?.getTime()).toBe(parentExpiry.getTime()); + }); + it('does not cap a normal send save so a following conversation refresh stays aligned', async () => { const conversationId = uuidv4(); const parentExpiry = new Date(Date.now() + 60 * 60 * 1000); diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts index 8d5f25fea8..fb8818fdb8 100644 --- a/packages/data-schemas/src/methods/message.ts +++ b/packages/data-schemas/src/methods/message.ts @@ -1,7 +1,11 @@ import { RetentionMode, isForcedTemporaryRetention } from 'librechat-data-provider'; import type { DeleteResult, FilterQuery, Model } from 'mongoose'; import type { AppConfig, IConversation, IMessage } from '~/types'; -import { cascadeForcedConversationRetention, createFallbackRetentionDate } from '~/utils/retention'; +import { + capForcedRetentionToParent, + cascadeForcedConversationRetention, + createFallbackRetentionDate, +} from '~/utils/retention'; import { createTempChatExpirationDate } from '~/utils/tempChatRetention'; import { tenantSafeBulkWrite } from '~/utils/tenantBulkWrite'; import logger from '~/config/winston'; @@ -163,17 +167,13 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa metadata?.capExpiryToConversation === true ) { const Conversation = mongoose.models.Conversation as Model; - const parent = await Conversation.findOne( - { conversationId, user: userId }, - 'isTemporary expiredAt', - ).lean<{ isTemporary?: boolean | null; expiredAt?: Date | null } | null>(); - if ( - parent?.isTemporary === true && - parent.expiredAt != null && - parent.expiredAt.getTime() < forcedExpiredAt.getTime() - ) { - update.expiredAt = parent.expiredAt; - } + update.expiredAt = await capForcedRetentionToParent( + Conversation, + Message, + userId, + conversationId, + forcedExpiredAt, + ); } const message = await Message.findOneAndUpdate( @@ -386,17 +386,13 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa const Conversation = mongoose.models.Conversation as Model; if (metadata?.capExpiryToConversation === true) { - const parent = await Conversation.findOne( - { conversationId, user: userId }, - 'isTemporary expiredAt', - ).lean<{ isTemporary?: boolean | null; expiredAt?: Date | null } | null>(); - if ( - parent?.isTemporary === true && - parent.expiredAt != null && - parent.expiredAt.getTime() < forcedExpiredAt.getTime() - ) { - forcedExpiredAt = parent.expiredAt; - } + forcedExpiredAt = await capForcedRetentionToParent( + Conversation, + Message, + userId, + conversationId, + forcedExpiredAt, + ); } await Message.updateOne( diff --git a/packages/data-schemas/src/utils/retention.ts b/packages/data-schemas/src/utils/retention.ts index fa79571783..512d6894fd 100644 --- a/packages/data-schemas/src/utils/retention.ts +++ b/packages/data-schemas/src/utils/retention.ts @@ -91,6 +91,36 @@ export const forceConversationMessagesTemporary = async ( ); }; +/** + * Caps a message-only forced save to a parent that already expires sooner than the freshly + * computed window. Returns the parent's earlier deadline (so the message cannot outlive it) + * and backfills the conversation's lagging messages to that deadline — the cascade leaves + * an already-conforming parent untouched, so older `expiredAt: null`/later messages would + * otherwise survive the parent's TTL. Returns the forced window unchanged when no earlier + * parent deadline applies. + */ +export const capForcedRetentionToParent = async ( + Conversation: Model, + Message: Model, + userId: string, + conversationId: string, + forcedExpiredAt: Date, +): Promise => { + const parent = await Conversation.findOne( + { conversationId, user: userId }, + 'isTemporary expiredAt', + ).lean<{ isTemporary?: boolean | null; expiredAt?: Date | null } | null>(); + if ( + parent?.isTemporary === true && + parent.expiredAt instanceof Date && + parent.expiredAt.getTime() < forcedExpiredAt.getTime() + ) { + await forceConversationMessagesTemporary(Message, userId, conversationId, parent.expiredAt); + return parent.expiredAt; + } + return forcedExpiredAt; +}; + /** * Converts or re-caps a parent conversation to the forced deadline and, when that first * brings the conversation into the forced window, backfills its lagging messages. Shared