diff --git a/packages/data-schemas/src/methods/conversation.spec.ts b/packages/data-schemas/src/methods/conversation.spec.ts index daba5c858a..ee161d0468 100644 --- a/packages/data-schemas/src/methods/conversation.spec.ts +++ b/packages/data-schemas/src/methods/conversation.spec.ts @@ -927,6 +927,43 @@ describe('Conversation Operations', () => { expect(messages[0].expiredAt?.getTime()).toBeLessThan(retainedUntil.getTime()); }); + it('re-caps an already temporary conversation and its messages to a shorter window', async () => { + const conversationId = uuidv4(); + const longerExpiry = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000); + await Conversation.create({ + conversationId, + user: 'user123', + endpoint: EModelEndpoint.openAI, + title: 'Already temporary chat', + isTemporary: true, + expiredAt: longerExpiry, + }); + await Message().create({ + messageId: uuidv4(), + conversationId, + user: 'user123', + text: 'older temporary message', + isTemporary: true, + expiredAt: longerExpiry, + }); + + await saveConvo( + { + userId: 'user123', + interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, + }, + { conversationId, isArchived: true }, + ); + + const convo = await Conversation.findOne({ conversationId }).lean(); + expect(convo?.expiredAt?.getTime()).toBeLessThan(longerExpiry.getTime()); + + const messages = await Message().find({ conversationId }).lean(); + expect(messages).toHaveLength(1); + expect(messages[0].isTemporary).toBe(true); + expect(messages[0].expiredAt?.getTime()).toBeLessThan(longerExpiry.getTime()); + }); + it('leaves messages untouched when the conversation is already ephemeral', async () => { const conversationId = uuidv4(); const ctx = { diff --git a/packages/data-schemas/src/methods/conversation.ts b/packages/data-schemas/src/methods/conversation.ts index 4b3be04a03..10c9548e4c 100644 --- a/packages/data-schemas/src/methods/conversation.ts +++ b/packages/data-schemas/src/methods/conversation.ts @@ -5,6 +5,7 @@ import type { AppConfig, IChatProjectDocument, IConversation, IMessage } from '~ import type { MessageMethods } from './message'; import { buildRetentionVisibilityFilter, + conversationNeedsForcedRetention, createFallbackRetentionDate, forceConversationMessagesTemporary, } from '~/utils/retention'; @@ -245,14 +246,18 @@ export function createConversationMethods( Object.prototype.hasOwnProperty.call(update, 'chatProjectId') || Object.prototype.hasOwnProperty.call(unsetFields, 'chatProjectId'); let previousChatProjectId: string | null = null; - let parentWasNotTemporary = false; + let parentRetention: { isTemporary?: boolean | null; expiredAt?: Date | null } | null = null; if (mayChangeProjectMembership || isForcedRetention) { const existing = await Conversation.findOne( { conversationId, user: userId }, - 'chatProjectId isTemporary', - ).lean<{ chatProjectId?: string | null; isTemporary?: boolean | null } | null>(); + 'chatProjectId isTemporary expiredAt', + ).lean<{ + chatProjectId?: string | null; + isTemporary?: boolean | null; + expiredAt?: Date | null; + } | null>(); previousChatProjectId = existing?.chatProjectId ?? null; - parentWasNotTemporary = existing != null && existing.isTemporary !== true; + parentRetention = existing; } if (newConversationId) { @@ -339,7 +344,11 @@ export function createConversationMethods( } const forcedExpiredAt = update.expiredAt; - if (isForcedRetention && parentWasNotTemporary && forcedExpiredAt instanceof Date) { + if ( + isForcedRetention && + forcedExpiredAt instanceof Date && + conversationNeedsForcedRetention(parentRetention, forcedExpiredAt) + ) { const Message = mongoose.models.Message as Model; await forceConversationMessagesTemporary(Message, userId, conversationId, forcedExpiredAt); } diff --git a/packages/data-schemas/src/methods/message.spec.ts b/packages/data-schemas/src/methods/message.spec.ts index 5579b13907..40bc49f490 100644 --- a/packages/data-schemas/src/methods/message.spec.ts +++ b/packages/data-schemas/src/methods/message.spec.ts @@ -1088,6 +1088,45 @@ describe('Message Operations', () => { } }); + it('re-caps an already temporary parent and its messages to a shorter ephemeral window', async () => { + const conversationId = uuidv4(); + const longerExpiry = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000); + await Conversation().create({ + conversationId, + user: 'user123', + endpoint: 'openAI', + title: 'Already temporary chat', + isTemporary: true, + expiredAt: longerExpiry, + }); + await Message.create({ + messageId: uuidv4(), + conversationId, + user: 'user123', + text: 'older temporary message', + isTemporary: true, + expiredAt: longerExpiry, + }); + + await saveMessage( + { + userId: 'user123', + interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, + }, + { messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' }, + ); + + const convo = await Conversation().findOne({ conversationId }).lean(); + expect(convo?.expiredAt?.getTime()).toBeLessThan(longerExpiry.getTime()); + + const messages = await getMessages({ conversationId, user: 'user123' }); + expect(messages).toHaveLength(2); + for (const message of messages) { + expect(message.isTemporary).toBe(true); + expect(message.expiredAt?.getTime()).toBeLessThan(longerExpiry.getTime()); + } + }); + 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 d6f8d6e277..ed55c9585a 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 { createFallbackRetentionDate, forceConversationMessagesTemporary } from '~/utils/retention'; +import { + createFallbackRetentionDate, + forceConversationMessagesTemporary, + forcedRetentionGapFilter, +} from '~/utils/retention'; import { createTempChatExpirationDate } from '~/utils/tempChatRetention'; import { tenantSafeBulkWrite } from '~/utils/tenantBulkWrite'; import logger from '~/config/winston'; @@ -178,7 +182,11 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa ) { const Conversation = mongoose.models.Conversation as Model; const convoResult = await Conversation.updateOne( - { conversationId, user: userId, isTemporary: { $ne: true } }, + { + conversationId, + user: userId, + ...forcedRetentionGapFilter(forcedExpiredAt), + }, { $set: { isTemporary: true, expiredAt: forcedExpiredAt } }, ); if (convoResult.modifiedCount > 0) { diff --git a/packages/data-schemas/src/utils/retention.ts b/packages/data-schemas/src/utils/retention.ts index 99bf3d5cef..0f288cd648 100644 --- a/packages/data-schemas/src/utils/retention.ts +++ b/packages/data-schemas/src/utils/retention.ts @@ -33,14 +33,51 @@ export const createFallbackRetentionDate = (now: number = Date.now()): Date => new Date(now + DEFAULT_RETENTION_HOURS * 60 * 60 * 1000); /** - * Applies forced-retention deadlines to a conversation's not-yet-temporary messages. + * Matches retention documents that do not yet conform to a forced (ephemeral) deadline: + * not temporary, missing an expiration, or expiring later than the forced window. The + * last clause re-caps documents carried over from a longer policy (`all`, or a longer + * `temporary` TTL) while leaving already-conforming temporary documents untouched. + */ +export const forcedRetentionGapFilter = < + T extends RetentionFilterDocument = RetentionFilterDocument, +>( + forcedExpiredAt: Date, +): FilterQuery => + ({ + $or: [ + { isTemporary: { $ne: true } }, + { expiredAt: null }, + { expiredAt: { $gt: forcedExpiredAt } }, + ], + }) as FilterQuery; + +/** + * In-memory counterpart of {@link forcedRetentionGapFilter} for a conversation's prior + * state: true when the parent must be re-capped to the forced deadline. + */ +export const conversationNeedsForcedRetention = ( + parent: RetentionFilterDocument | null | undefined, + forcedExpiredAt: Date, +): boolean => { + if (parent == null) { + return false; + } + if (parent.isTemporary !== true || parent.expiredAt == null) { + return true; + } + return parent.expiredAt.getTime() > forcedExpiredAt.getTime(); +}; + +/** + * Applies forced-retention deadlines to a conversation's messages that do not yet + * conform to the forced window. * * Forced (ephemeral) retention must cover existing messages too. A conversation that - * predates the mode keeps non-temporary messages — `expiredAt: null` permanent messages, - * or `isTemporary: false` messages with a future `expiredAt` carried over from `all` - * retention — that outlive the converted conversation. Targeting `isTemporary !== true` - * pulls both onto the ephemeral schedule and stays a no-op once a conversation has been - * converted. + * predates the mode keeps non-conforming messages — `expiredAt: null` permanent messages, + * `isTemporary: false` messages carried over from `all` retention, or temporary messages + * whose `expiredAt` is later than a newly shortened window — that would otherwise outlive + * the converted conversation. The gap filter pulls all of them onto the ephemeral schedule + * and stays a no-op once a conversation already conforms. */ export const forceConversationMessagesTemporary = async ( Message: Model, @@ -49,7 +86,7 @@ export const forceConversationMessagesTemporary = async ( expiredAt: Date, ): Promise => { await Message.updateMany( - { conversationId, user: userId, isTemporary: { $ne: true } }, + { conversationId, user: userId, ...forcedRetentionGapFilter(expiredAt) }, { $set: { isTemporary: true, expiredAt } }, ); };