From da5b1e222b34f82ce8b073d10596232fcacfb9e2 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:52:54 +0200 Subject: [PATCH] fix: backfill lagging messages when capping to an already-sooner parent When a message-only forced save caps to a parent that is already temporary and expires before the freshly computed window, the gated cascade leaves the parent untouched (modifiedCount 0) and skips the message backfill. Older messages with expiredAt null or a later deadline then survive after the parent's TTL deletes the conversation. Extract the cap logic into capForcedRetentionToParent, shared by saveMessage and applyForcedRetention, which now also backfills the conversation's non-conforming messages to the parent's earlier deadline. The hot normal-send path does not cap, so it keeps the gated cascade with no extra per-message work. --- .../data-schemas/src/methods/message.spec.ts | 33 +++++++++++++++ packages/data-schemas/src/methods/message.ts | 42 +++++++++---------- packages/data-schemas/src/utils/retention.ts | 30 +++++++++++++ 3 files changed, 82 insertions(+), 23 deletions(-) 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