From e9835fdb2f644d9007636a5cb9737ede114432bc Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sun, 5 Jul 2026 02:32:01 +0200 Subject: [PATCH] fix: cap saveConvo children even when the parent already conforms saveConvo only ran the message/share/file caps when the touched conversation itself needed forced conversion, so an archive/pin/title save on an already-temporary chat with an earlier deadline skipped the child caps and any lagging rows from before the mode switch or a partial backfill kept expiredAt: null or a later deadline past the parent's TTL. Run the child caps whenever forced retention resolves an active deadline for a pre-existing conversation, matching the single cascade's self-healing behavior; each cap is an indexed no-op once the chat's children conform. The old "leaves messages untouched once ephemeral" expectation pinned the skip this change removes, so it now asserts the permanent row is healed instead. --- .../src/methods/conversation.spec.ts | 49 +++++++++++++++++-- .../data-schemas/src/methods/conversation.ts | 17 +++---- 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/packages/data-schemas/src/methods/conversation.spec.ts b/packages/data-schemas/src/methods/conversation.spec.ts index 36582c852d..7d13db60e9 100644 --- a/packages/data-schemas/src/methods/conversation.spec.ts +++ b/packages/data-schemas/src/methods/conversation.spec.ts @@ -919,6 +919,46 @@ describe('Conversation Operations', () => { expect(file?.expiredAt).toBeInstanceOf(Date); }); + it('caps lagging children when saving an already-conforming temporary conversation', async () => { + const conversationId = uuidv4(); + const fileId = uuidv4(); + const parentDeadline = new Date(Date.now() + 60 * 60 * 1000); + const SharedLink = mongoose.models.SharedLink as mongoose.Model; + + await Conversation.create({ + conversationId, + user: 'user123', + endpoint: EModelEndpoint.openAI, + title: 'Conforming temporary chat', + isTemporary: true, + expiredAt: parentDeadline, + }); + await SharedLink.create({ conversationId, user: 'user123', shareId: uuidv4() }); + await File().collection.insertOne({ + file_id: fileId, + conversationId, + user: new mongoose.Types.ObjectId(), + expiredAt: null, + }); + + await saveConvo( + { + userId: 'user123', + interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, + }, + { conversationId, isArchived: true }, + ); + + const share = await SharedLink.findOne({ conversationId }).lean(); + expect(share?.expiredAt?.getTime()).toBe(parentDeadline.getTime()); + + const file = await File().findOne({ file_id: fileId }).lean(); + expect(file?.expiredAt?.getTime()).toBe(parentDeadline.getTime()); + + const convo = await Conversation.findOne({ conversationId }).lean(); + expect(convo?.expiredAt?.getTime()).toBe(parentDeadline.getTime()); + }); + it('keeps the chat convertible when a child backfill fails during forced conversion', async () => { const conversationId = uuidv4(); await Conversation.create({ @@ -1086,25 +1126,28 @@ describe('Conversation Operations', () => { expect(messages[0].expiredAt?.getTime()).toBeLessThan(longerExpiry.getTime()); }); - it('leaves messages untouched when the conversation is already ephemeral', async () => { + it('heals a permanent message row inside an already-ephemeral conversation', async () => { const conversationId = uuidv4(); const ctx = { userId: 'user123', interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, }; await saveConvo(ctx, { conversationId, title: 'Already ephemeral' }); + const parent = await Conversation.findOne({ conversationId }).lean(); + expect(parent?.expiredAt).toBeInstanceOf(Date); const lateMessage = await Message().create({ messageId: uuidv4(), conversationId, user: 'user123', - text: 'added after conversion', + text: 'legacy row without retention fields', }); await saveConvo(ctx, { conversationId, isArchived: true }); const reloaded = await Message().findById(lateMessage._id).lean(); - expect(reloaded?.expiredAt ?? null).toBeNull(); + expect(reloaded?.isTemporary).toBe(true); + expect(reloaded?.expiredAt?.getTime()).toBe(parent?.expiredAt?.getTime()); }); it('caps an existing permanent shared link when ephemeral converts a conversation', async () => { diff --git a/packages/data-schemas/src/methods/conversation.ts b/packages/data-schemas/src/methods/conversation.ts index eea856170f..e24ab2395c 100644 --- a/packages/data-schemas/src/methods/conversation.ts +++ b/packages/data-schemas/src/methods/conversation.ts @@ -15,7 +15,6 @@ import { capConversationFiles, capConversationSharedLinks, capForcedRetentionExpiry, - conversationNeedsForcedRetention, createFallbackRetentionDate, forceConversationMessagesTemporary, } from '~/utils/retention'; @@ -316,16 +315,14 @@ export function createConversationMethods( const forcedExpiredAt = update.expiredAt; /** - * Backfill the dependent messages, shares, and files before converting the parent. The - * findOneAndUpdate below writes isTemporary/expiredAt on the conversation, so if a child - * update failed after it, a retried save would reload an already-conforming parent - * (conversationNeedsForcedRetention false) and permanently skip the child rows. + * Cap the dependent messages, shares, and files whenever forced retention resolves an + * active deadline for a pre-existing conversation, before converting the parent. Running + * before the findOneAndUpdate keeps a failed child from leaving an already-conforming + * parent behind (a retried save would skip the child rows), and running for conforming + * parents too heals children that lag from before the mode switch or a partial earlier + * backfill — each cap is an indexed no-op once the chat's children conform. */ - if ( - isForcedRetention && - forcedExpiredAt instanceof Date && - conversationNeedsForcedRetention(parentRetention, forcedExpiredAt) - ) { + if (isForcedRetention && forcedExpiredAt instanceof Date && parentRetention != null) { const Message = mongoose.models.Message as Model; const SharedLink = mongoose.models.SharedLink as Model; const File = mongoose.models.File as Model;