From acd150d4afbc01f9573a4cf060b1cf416f66ecfa Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:52:27 +0200 Subject: [PATCH] fix: cap normal message saves to the preserved parent expiry saveConvo now preserves a conversation's earlier ephemeral deadline instead of refreshing it, so the message cap can no longer be opt-in. A normal send to an existing ephemeral conversation whose parent already expires sooner saved the new message with a fresh, later TTL; saveConvo kept the parent at the earlier deadline and the cascade skipped (parent already conforms), leaving the new message to outlive the deleted conversation. Cap every forced-retention message save to the parent's deadline, not just the opt-in message-only paths. Message-only saves still backfill the parent's other messages and shares; normal saves only cap the message itself and let saveConvo and the cascade handle the rest. --- .../data-schemas/src/methods/message.spec.ts | 8 +++- packages/data-schemas/src/methods/message.ts | 42 +++++++++++-------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/packages/data-schemas/src/methods/message.spec.ts b/packages/data-schemas/src/methods/message.spec.ts index 0c9e2de4a3..e14fd1fee2 100644 --- a/packages/data-schemas/src/methods/message.spec.ts +++ b/packages/data-schemas/src/methods/message.spec.ts @@ -978,7 +978,7 @@ describe('Message Operations', () => { expect(lagging?.expiredAt?.getTime()).toBe(parentExpiry.getTime()); }); - it('does not cap a normal send save so a following conversation refresh stays aligned', async () => { + it('caps a normal send to a parent expiring sooner so it cannot outlive the conversation', async () => { const conversationId = uuidv4(); const parentExpiry = new Date(Date.now() + 60 * 60 * 1000); await Conversation().create({ @@ -998,7 +998,11 @@ describe('Message Operations', () => { { messageId: uuidv4(), conversationId, text: 'follow-up', user: 'user123' }, ); - expect(saved?.expiredAt?.getTime()).toBeGreaterThan(parentExpiry.getTime()); + 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 () => { diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts index d40267e5f1..176d3ab503 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 } from 'mongoose'; import type { AppConfig, IConversation, IMessage, ISharedLink } from '~/types'; import { + capForcedRetentionExpiry, capForcedRetentionToParent, cascadeForcedConversationRetention, cascadeForcedRetentionByTag, @@ -162,26 +163,31 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa const forcedExpiredAt = update.expiredAt; const isForcedRetention = isForcedTemporaryRetention(interfaceConfig?.retentionMode); /** - * Message-only saves (branch/artifact/abort) never run `saveConvo`, so the new - * message must not outlive a parent that already expires sooner. Callers that DO - * refresh the conversation afterward must not opt in, otherwise the message keeps - * the stale deadline and the TTL index deletes it before the refreshed conversation. + * Under forced retention the new message must never outlive a parent that already + * expires sooner, since `saveConvo` preserves that earlier deadline rather than + * refreshing it. Message-only saves (branch/artifact/abort) additionally backfill the + * parent's other messages and shares because no `saveConvo` follows to cascade; normal + * saves only cap the message itself and let the conversation cascade handle the rest. */ - if ( - isForcedRetention && - forcedExpiredAt instanceof Date && - metadata?.capExpiryToConversation === true - ) { + if (isForcedRetention && forcedExpiredAt instanceof Date) { const Conversation = mongoose.models.Conversation as Model; - const SharedLink = mongoose.models.SharedLink as Model; - update.expiredAt = await capForcedRetentionToParent( - Conversation, - Message, - SharedLink, - userId, - conversationId, - forcedExpiredAt, - ); + if (metadata?.capExpiryToConversation === true) { + const SharedLink = mongoose.models.SharedLink as Model; + update.expiredAt = await capForcedRetentionToParent( + Conversation, + Message, + SharedLink, + userId, + conversationId, + forcedExpiredAt, + ); + } else { + const parent = await Conversation.findOne( + { conversationId, user: userId }, + 'expiredAt', + ).lean<{ expiredAt?: Date | null } | null>(); + update.expiredAt = capForcedRetentionExpiry(parent?.expiredAt, forcedExpiredAt); + } } const message = await Message.findOneAndUpdate(