mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-11 00:33:40 +00:00
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.
This commit is contained in:
parent
3e2bd05bbd
commit
e9835fdb2f
2 changed files with 53 additions and 13 deletions
|
|
@ -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<ISharedLink>;
|
||||
|
||||
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<IMongoFile>();
|
||||
expect(file?.expiredAt?.getTime()).toBe(parentDeadline.getTime());
|
||||
|
||||
const convo = await Conversation.findOne<IConversation>({ 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<IConversation>({ 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 () => {
|
||||
|
|
|
|||
|
|
@ -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<IMessage>;
|
||||
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
|
||||
const File = mongoose.models.File as Model<IMongoFile>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue