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.
This commit is contained in:
Marco Beretta 2026-06-23 17:39:01 +02:00
parent b9602214e2
commit 34dfd71e3a
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
3 changed files with 52 additions and 11 deletions

View file

@ -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);

View file

@ -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<IConversation>;
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
await cascadeForcedConversationRetention(
@ -223,7 +224,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
SharedLink,
userId,
conversationId,
forcedExpiredAt,
cascadeExpiredAt,
);
}

View file

@ -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<IConversation>,
@ -130,15 +134,10 @@ export const capForcedRetentionToParent = async (
conversationId: string,
forcedExpiredAt: Date,
): Promise<Date> => {
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;