From df36cd8d44bb8056e9347394005f24b350a4c5ae Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sun, 21 Jun 2026 02:35:55 +0200 Subject: [PATCH] fix: convert active retained parents when forcing ephemeral retention The forced-retention cascade keyed conversion off expiredAt: null, so a switch from all to ephemeral retention skipped conversations that were already isTemporary: false with a future expiredAt. Message-only paths (branch, artifact, abort) then produced a temporary message under a parent that stayed visible in history via the active non-temporary branch of the visibility filter. Gate the cascade on isTemporary !== true instead so saveMessage, saveConvo, and the message backfill all convert non-temporary parents and their messages regardless of an existing active expiration, while remaining a no-op once a conversation is already temporary. --- .../src/methods/conversation.spec.ts | 38 ++++++++++++++++++ .../data-schemas/src/methods/conversation.ts | 10 ++--- .../data-schemas/src/methods/message.spec.ts | 40 +++++++++++++++++++ packages/data-schemas/src/methods/message.ts | 2 +- packages/data-schemas/src/utils/retention.ts | 14 ++++--- 5 files changed, 92 insertions(+), 12 deletions(-) diff --git a/packages/data-schemas/src/methods/conversation.spec.ts b/packages/data-schemas/src/methods/conversation.spec.ts index 1ce883e44a..0668c5c76e 100644 --- a/packages/data-schemas/src/methods/conversation.spec.ts +++ b/packages/data-schemas/src/methods/conversation.spec.ts @@ -889,6 +889,44 @@ describe('Conversation Operations', () => { } }); + it('converts an active retained (all-mode) conversation when switching to ephemeral', async () => { + const conversationId = uuidv4(); + const retainedUntil = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000); + await Conversation.create({ + conversationId, + user: 'user123', + endpoint: EModelEndpoint.openAI, + title: 'Retained all-mode chat', + isTemporary: false, + expiredAt: retainedUntil, + }); + await Message().create({ + messageId: uuidv4(), + conversationId, + user: 'user123', + text: 'retained', + isTemporary: false, + expiredAt: retainedUntil, + }); + + await saveConvo( + { + userId: 'user123', + interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, + }, + { conversationId, isArchived: true }, + ); + + const convo = await Conversation.findOne({ conversationId }).lean(); + expect(convo?.isTemporary).toBe(true); + expect(convo?.expiredAt?.getTime()).toBeLessThan(retainedUntil.getTime()); + + const messages = await Message().find({ conversationId }).lean(); + expect(messages).toHaveLength(1); + expect(messages[0].isTemporary).toBe(true); + expect(messages[0].expiredAt?.getTime()).toBeLessThan(retainedUntil.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 89ac66858a..f89ee5709e 100644 --- a/packages/data-schemas/src/methods/conversation.ts +++ b/packages/data-schemas/src/methods/conversation.ts @@ -245,14 +245,14 @@ export function createConversationMethods( Object.prototype.hasOwnProperty.call(update, 'chatProjectId') || Object.prototype.hasOwnProperty.call(unsetFields, 'chatProjectId'); let previousChatProjectId: string | null = null; - let wasConversationPermanent = false; + let parentWasNotTemporary = false; if (mayChangeProjectMembership || isForcedRetention) { const existing = await Conversation.findOne( { conversationId, user: userId }, - 'chatProjectId expiredAt', - ).lean<{ chatProjectId?: string | null; expiredAt?: Date | null } | null>(); + 'chatProjectId isTemporary', + ).lean<{ chatProjectId?: string | null; isTemporary?: boolean | null } | null>(); previousChatProjectId = existing?.chatProjectId ?? null; - wasConversationPermanent = existing != null && existing.expiredAt == null; + parentWasNotTemporary = existing != null && existing.isTemporary !== true; } if (newConversationId) { @@ -339,7 +339,7 @@ export function createConversationMethods( } const forcedExpiredAt = update.expiredAt; - if (isForcedRetention && wasConversationPermanent && forcedExpiredAt instanceof Date) { + if (isForcedRetention && parentWasNotTemporary && forcedExpiredAt instanceof Date) { 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 cd0d57c1cd..ff332188f0 100644 --- a/packages/data-schemas/src/methods/message.spec.ts +++ b/packages/data-schemas/src/methods/message.spec.ts @@ -793,6 +793,46 @@ describe('Message Operations', () => { } }); + it('converts an active retained (all-mode) parent when switching to ephemeral', async () => { + const conversationId = uuidv4(); + const retainedUntil = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000); + await Conversation().create({ + conversationId, + user: 'user123', + endpoint: 'openAI', + title: 'Retained all-mode chat', + isTemporary: false, + expiredAt: retainedUntil, + }); + await Message.create({ + messageId: uuidv4(), + conversationId, + user: 'user123', + text: 'retained', + isTemporary: false, + expiredAt: retainedUntil, + }); + + 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?.isTemporary).toBe(true); + expect(convo?.expiredAt?.getTime()).toBeLessThan(retainedUntil.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(retainedUntil.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 e7b3ecf95b..951a5326e0 100644 --- a/packages/data-schemas/src/methods/message.ts +++ b/packages/data-schemas/src/methods/message.ts @@ -169,7 +169,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa ) { const Conversation = mongoose.models.Conversation as Model; const convoResult = await Conversation.updateOne( - { conversationId, user: userId, expiredAt: null }, + { conversationId, user: userId, isTemporary: { $ne: true } }, { $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 b98cbc9820..99bf3d5cef 100644 --- a/packages/data-schemas/src/utils/retention.ts +++ b/packages/data-schemas/src/utils/retention.ts @@ -33,12 +33,14 @@ 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-expiring messages. + * Applies forced-retention deadlines to a conversation's not-yet-temporary messages. * - * Forced (ephemeral) retention must cover existing messages too: a conversation that - * predates the mode keeps `expiredAt: null` messages that the TTL index never removes, - * so they would outlive the conversation. Only messages still lacking an expiration are - * touched, making this a no-op once a conversation has been converted. + * 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. */ export const forceConversationMessagesTemporary = async ( Message: Model, @@ -47,7 +49,7 @@ export const forceConversationMessagesTemporary = async ( expiredAt: Date, ): Promise => { await Message.updateMany( - { conversationId, user: userId, expiredAt: null }, + { conversationId, user: userId, isTemporary: { $ne: true } }, { $set: { isTemporary: true, expiredAt } }, ); };