diff --git a/packages/data-schemas/src/methods/message.spec.ts b/packages/data-schemas/src/methods/message.spec.ts index 26a2991838..f3b215c956 100644 --- a/packages/data-schemas/src/methods/message.spec.ts +++ b/packages/data-schemas/src/methods/message.spec.ts @@ -929,6 +929,47 @@ describe('Message Operations', () => { expect(saved?.expiredAt?.getTime()).toBeLessThanOrEqual(convo?.expiredAt?.getTime() ?? 0); }); + it('caps a message-only save to an all-mode parent expiring sooner without extending it', async () => { + const conversationId = uuidv4(); + const parentExpiry = new Date(Date.now() + 60 * 60 * 1000); + await Conversation().create({ + conversationId, + user: 'user123', + endpoint: 'openAI', + title: 'All-mode chat expiring soon', + isTemporary: false, + expiredAt: parentExpiry, + }); + const olderMessageId = uuidv4(); + await Message.create({ + messageId: olderMessageId, + conversationId, + user: 'user123', + text: 'older retained message', + isTemporary: false, + expiredAt: parentExpiry, + }); + + const saved = await saveMessage( + { + userId: 'user123', + interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, + }, + { messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' }, + { context: 'branch', capExpiryToConversation: true }, + ); + + expect(saved?.expiredAt?.getTime()).toBe(parentExpiry.getTime()); + + const convo = await Conversation().findOne({ conversationId }).lean(); + expect(convo?.isTemporary).toBe(true); + expect(convo?.expiredAt?.getTime()).toBe(parentExpiry.getTime()); + + const older = await Message.findOne({ messageId: olderMessageId }).lean(); + expect(older?.isTemporary).toBe(true); + expect(older?.expiredAt?.getTime()).toBe(parentExpiry.getTime()); + }); + it('backfills lagging messages when the parent already expires sooner', 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 ce225b8aa1..f44e3d4c20 100644 --- a/packages/data-schemas/src/methods/message.ts +++ b/packages/data-schemas/src/methods/message.ts @@ -214,7 +214,8 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa message.isTemporary = false; } - if (isForcedRetention && forcedExpiredAt instanceof Date) { + const cascadeExpiredAt = update.expiredAt; + if (isForcedRetention && cascadeExpiredAt instanceof Date) { const Conversation = mongoose.models.Conversation as Model; const SharedLink = mongoose.models.SharedLink as Model; await cascadeForcedConversationRetention( @@ -223,7 +224,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa SharedLink, userId, conversationId, - forcedExpiredAt, + cascadeExpiredAt, ); } diff --git a/packages/data-schemas/src/utils/retention.ts b/packages/data-schemas/src/utils/retention.ts index ce96414049..804adfbffd 100644 --- a/packages/data-schemas/src/utils/retention.ts +++ b/packages/data-schemas/src/utils/retention.ts @@ -121,6 +121,10 @@ export const capConversationSharedLinks = async ( * 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. + * + * Any active earlier deadline is honored regardless of `isTemporary`: an `all`-mode parent + * carried over with a sooner `expiredAt` must not be extended to the fresh window just + * because it is not yet temporary — the cascade converts it afterward using this deadline. */ export const capForcedRetentionToParent = async ( Conversation: Model, @@ -130,15 +134,10 @@ export const capForcedRetentionToParent = async ( 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() - ) { + const parent = await Conversation.findOne({ conversationId, user: userId }, 'expiredAt').lean<{ + expiredAt?: Date | null; + } | null>(); + if (parent?.expiredAt instanceof Date && parent.expiredAt.getTime() < forcedExpiredAt.getTime()) { await forceConversationMessagesTemporary(Message, userId, conversationId, parent.expiredAt); await capConversationSharedLinks(SharedLink, userId, conversationId, parent.expiredAt); return parent.expiredAt;