From 34dfd71e3a309720336d63ebef355b9793d90532 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:39:01 +0200 Subject: [PATCH] fix: preserve earlier parent expiry on message-only forced saves A message-only forced save (branch/artifact/abort/edit) to a parent carrying an active expiry sooner than the freshly computed ephemeral window only capped when the parent was already temporary. An all-mode parent (isTemporary: false) with an earlier active deadline skipped the cap, so the touched message took the later window and the cascade then rewrote the parent and its messages to that later date, extending data that was meant to expire sooner. Cap on any active parent expiredAt earlier than the forced window, regardless of isTemporary, and feed the capped deadline into the conversation cascade so it converts the parent without extending it. --- .../data-schemas/src/methods/message.spec.ts | 41 +++++++++++++++++++ packages/data-schemas/src/methods/message.ts | 5 ++- packages/data-schemas/src/utils/retention.ts | 17 ++++---- 3 files changed, 52 insertions(+), 11 deletions(-) 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;