diff --git a/api/server/middleware/abortMiddleware.js b/api/server/middleware/abortMiddleware.js index feed002b0e..80e6e37baf 100644 --- a/api/server/middleware/abortMiddleware.js +++ b/api/server/middleware/abortMiddleware.js @@ -173,7 +173,7 @@ async function abortMessage(req, res) { interfaceConfig: req?.config?.interfaceConfig, }, { ...responseMessage, user: userId }, - { context: 'api/server/middleware/abortMiddleware.js' }, + { context: 'api/server/middleware/abortMiddleware.js', capExpiryToConversation: true }, ); // Get conversation for title diff --git a/api/server/routes/messages.js b/api/server/routes/messages.js index 618043a295..effd4cda29 100644 --- a/api/server/routes/messages.js +++ b/api/server/routes/messages.js @@ -175,7 +175,7 @@ router.post('/branch', configMiddleware, async (req, res) => { interfaceConfig: req?.config?.interfaceConfig, }, newMessage, - { context: 'POST /api/messages/branch' }, + { context: 'POST /api/messages/branch', capExpiryToConversation: true }, ); if (!savedMessage) { @@ -256,7 +256,7 @@ router.post('/artifact/:messageId', configMiddleware, async (req, res) => { content: message.content, user: req.user.id, }, - { context: 'POST /api/messages/artifact/:messageId' }, + { context: 'POST /api/messages/artifact/:messageId', capExpiryToConversation: true }, ); res.status(200).json({ diff --git a/packages/data-schemas/src/methods/message.spec.ts b/packages/data-schemas/src/methods/message.spec.ts index 69c5b811c2..1c98c95f8e 100644 --- a/packages/data-schemas/src/methods/message.spec.ts +++ b/packages/data-schemas/src/methods/message.spec.ts @@ -899,7 +899,7 @@ describe('Message Operations', () => { } }); - it('caps a message to a sooner parent expiry instead of orphaning it', async () => { + it('caps a message-only save 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({ @@ -917,6 +917,7 @@ describe('Message Operations', () => { interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, }, { messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' }, + { context: 'branch', capExpiryToConversation: true }, ); expect(saved?.expiredAt?.getTime()).toBe(parentExpiry.getTime()); @@ -926,6 +927,29 @@ describe('Message Operations', () => { expect(saved?.expiredAt?.getTime()).toBeLessThanOrEqual(convo?.expiredAt?.getTime() ?? 0); }); + 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); + 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: 'follow-up', user: 'user123' }, + ); + + expect(saved?.expiredAt?.getTime()).toBeGreaterThan(parentExpiry.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 08da85ac27..01831317f4 100644 --- a/packages/data-schemas/src/methods/message.ts +++ b/packages/data-schemas/src/methods/message.ts @@ -2,7 +2,6 @@ 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, @@ -36,7 +35,7 @@ export interface MessageMethods { saveMessage( ctx: { userId: string; isTemporary?: boolean; interfaceConfig?: AppConfig['interfaceConfig'] }, params: Partial & { newMessageId?: string }, - metadata?: { context?: string }, + metadata?: { context?: string; capExpiryToConversation?: boolean }, ): Promise; bulkSaveMessages( messages: Array>, @@ -102,7 +101,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa interfaceConfig?: AppConfig['interfaceConfig']; }, params: Partial & { newMessageId?: string }, - metadata?: { context?: string }, + metadata?: { context?: string; capExpiryToConversation?: boolean }, ) { if (!userId) { throw new Error('User not authenticated'); @@ -168,19 +167,28 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa const forcedExpiredAt = update.expiredAt; const isForcedRetention = isForcedTemporaryRetention(interfaceConfig?.retentionMode); - let parentRetention: { isTemporary?: boolean | null; expiredAt?: Date | null } | null = null; - if (isForcedRetention && forcedExpiredAt instanceof Date) { + /** + * 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. + */ + if ( + isForcedRetention && + forcedExpiredAt instanceof Date && + metadata?.capExpiryToConversation === true + ) { const Conversation = mongoose.models.Conversation as Model; - parentRetention = await Conversation.findOne( + const parent = 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() + parent?.isTemporary === true && + parent.expiredAt != null && + parent.expiredAt.getTime() < forcedExpiredAt.getTime() ) { - update.expiredAt = parentRetention.expiredAt; + update.expiredAt = parent.expiredAt; } } @@ -203,11 +211,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa message.isTemporary = false; } - if ( - isForcedRetention && - forcedExpiredAt instanceof Date && - conversationNeedsForcedRetention(parentRetention, forcedExpiredAt) - ) { + if (isForcedRetention && forcedExpiredAt instanceof Date) { const Conversation = mongoose.models.Conversation as Model; const convoResult = await Conversation.updateOne( {