From ec8eec6eafd7257fa8351c72be3c1627c4a2f566 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Mon, 22 Jun 2026 09:17:25 +0200 Subject: [PATCH] fix: cap message expiry to parent on message-only forced saves A message-only forced save (branch, artifact, abort) does not run saveConvo, so on an existing ephemeral conversation whose expiredAt is already sooner than the freshly computed window the parent was left untouched while the message received the later deadline. The conversation could then be TTL-deleted first, leaving the new or edited message orphaned until its later expiry. Read the parent once before saving the message and cap the message deadline to an already-temporary parent that expires sooner, so the message never outlives its conversation. The same read gates the existing conversion/re-cap cascade, which keeps extending or shortening the parent when that is the correct action. --- .../data-schemas/src/methods/message.spec.ts | 27 +++++++++++++++++++ packages/data-schemas/src/methods/message.ts | 26 +++++++++++++++--- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/data-schemas/src/methods/message.spec.ts b/packages/data-schemas/src/methods/message.spec.ts index aabfffa651..69c5b811c2 100644 --- a/packages/data-schemas/src/methods/message.spec.ts +++ b/packages/data-schemas/src/methods/message.spec.ts @@ -899,6 +899,33 @@ describe('Message Operations', () => { } }); + it('caps a message to a sooner parent expiry instead of orphaning it', 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 saved = await saveMessage( + { + userId: 'user123', + interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, + }, + { messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' }, + ); + + expect(saved?.expiredAt?.getTime()).toBe(parentExpiry.getTime()); + + const convo = await Conversation().findOne({ conversationId }).lean(); + expect(convo?.expiredAt?.getTime()).toBe(parentExpiry.getTime()); + expect(saved?.expiredAt?.getTime()).toBeLessThanOrEqual(convo?.expiredAt?.getTime() ?? 0); + }); + it('does not touch the parent conversation outside forced retention', async () => { const conversationId = uuidv4(); await Conversation().create({ diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts index 4d1c818ba1..08da85ac27 100644 --- a/packages/data-schemas/src/methods/message.ts +++ b/packages/data-schemas/src/methods/message.ts @@ -2,6 +2,7 @@ import { RetentionMode, isForcedTemporaryRetention } from 'librechat-data-provid import type { DeleteResult, FilterQuery, Model, PipelineStage } from 'mongoose'; import type { AppConfig, IConversation, IMessage } from '~/types'; import { + conversationNeedsForcedRetention, createFallbackRetentionDate, forceConversationMessagesTemporary, forcedRetentionGapFilter, @@ -164,6 +165,25 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa logger.info(`---\`saveMessage\` context: ${metadata?.context}`); update.tokenCount = 0; } + + const forcedExpiredAt = update.expiredAt; + const isForcedRetention = isForcedTemporaryRetention(interfaceConfig?.retentionMode); + let parentRetention: { isTemporary?: boolean | null; expiredAt?: Date | null } | null = null; + if (isForcedRetention && forcedExpiredAt instanceof Date) { + const Conversation = mongoose.models.Conversation as Model; + parentRetention = await Conversation.findOne( + { conversationId, user: userId }, + 'isTemporary expiredAt', + ).lean<{ isTemporary?: boolean | null; expiredAt?: Date | null } | null>(); + if ( + parentRetention?.isTemporary === true && + parentRetention.expiredAt != null && + parentRetention.expiredAt.getTime() < forcedExpiredAt.getTime() + ) { + update.expiredAt = parentRetention.expiredAt; + } + } + const message = await Message.findOneAndUpdate( { messageId: params.messageId, user: userId }, update, @@ -183,10 +203,10 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa message.isTemporary = false; } - const forcedExpiredAt = update.expiredAt; if ( - isForcedTemporaryRetention(interfaceConfig?.retentionMode) && - forcedExpiredAt instanceof Date + isForcedRetention && + forcedExpiredAt instanceof Date && + conversationNeedsForcedRetention(parentRetention, forcedExpiredAt) ) { const Conversation = mongoose.models.Conversation as Model; const convoResult = await Conversation.updateOne(