From 6d3a0b955d9a041d4e54a00a0f6718d23ea116fe Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:18:10 +0200 Subject: [PATCH] refactor: centralize ephemeral retention enforcement --- api/server/controllers/agents/request.js | 5 +- api/server/middleware/abortMiddleware.js | 2 +- api/server/middleware/denyRequest.js | 5 +- api/server/middleware/error.js | 5 +- api/server/routes/__tests__/share.spec.js | 45 +- .../routes/agents/__tests__/abort.spec.js | 1 - api/server/routes/agents/index.js | 5 +- api/server/routes/messages.js | 28 +- api/server/routes/share.js | 42 +- api/server/routes/tags.js | 33 +- api/server/utils/import/fork.spec.js | 32 +- api/server/utils/import/importBatchBuilder.js | 23 +- .../utils/import/importers-timestamp.spec.js | 1 + api/server/utils/import/importers.spec.js | 25 +- config/migrate-ephemeral-retention.js | 240 ----- package.json | 4 +- packages/api/src/app/permissions.spec.ts | 27 + packages/api/src/app/permissions.ts | 5 +- packages/api/src/files/retention.spec.ts | 56 +- packages/api/src/files/retention.ts | 52 +- .../src/methods/chatProject.spec.ts | 15 +- .../data-schemas/src/methods/chatProject.ts | 88 +- .../src/methods/conversation.spec.ts | 166 +++- .../data-schemas/src/methods/conversation.ts | 106 +- .../src/methods/convoStructure.spec.ts | 8 +- .../data-schemas/src/methods/file.spec.ts | 23 + packages/data-schemas/src/methods/file.ts | 25 +- packages/data-schemas/src/methods/index.ts | 34 +- .../data-schemas/src/methods/message.spec.ts | 922 +----------------- packages/data-schemas/src/methods/message.ts | 259 +---- packages/data-schemas/src/utils/retention.ts | 753 ++++---------- 31 files changed, 681 insertions(+), 2354 deletions(-) delete mode 100644 config/migrate-ephemeral-retention.js diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index 76614625f1..49d9329f0c 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -443,10 +443,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit interfaceConfig: req?.config?.interfaceConfig, }, partialMessage, - { - context: 'api/server/controllers/agents/request.js - partial response on disconnect', - capExpiryToConversation: true, - }, + { context: 'api/server/controllers/agents/request.js - partial response on disconnect' }, ); logger.debug( diff --git a/api/server/middleware/abortMiddleware.js b/api/server/middleware/abortMiddleware.js index 80e6e37baf..feed002b0e 100644 --- a/api/server/middleware/abortMiddleware.js +++ b/api/server/middleware/abortMiddleware.js @@ -173,7 +173,7 @@ async function abortMessage(req, res) { interfaceConfig: req?.config?.interfaceConfig, }, { ...responseMessage, user: userId }, - { context: 'api/server/middleware/abortMiddleware.js', capExpiryToConversation: true }, + { context: 'api/server/middleware/abortMiddleware.js' }, ); // Get conversation for title diff --git a/api/server/middleware/denyRequest.js b/api/server/middleware/denyRequest.js index 41ba36b3bd..86054d0a23 100644 --- a/api/server/middleware/denyRequest.js +++ b/api/server/middleware/denyRequest.js @@ -49,10 +49,7 @@ const denyRequest = async (req, res, errorMessage) => { interfaceConfig: req?.config?.interfaceConfig, }, { ...userMessage, user: req.user.id }, - { - context: `api/server/middleware/denyRequest.js - ${responseText}`, - capExpiryToConversation: true, - }, + { context: `api/server/middleware/denyRequest.js - ${responseText}` }, ); } diff --git a/api/server/middleware/error.js b/api/server/middleware/error.js index 5dce79f8f4..9c97f6bbfa 100644 --- a/api/server/middleware/error.js +++ b/api/server/middleware/error.js @@ -54,10 +54,7 @@ const sendError = async (req, res, options, callback) => { interfaceConfig: req?.config?.interfaceConfig, }, { ...errorMessage, user }, - { - context: 'api/server/utils/streamResponse.js - sendError', - capExpiryToConversation: true, - }, + { context: 'api/server/utils/streamResponse.js - sendError' }, ); } diff --git a/api/server/routes/__tests__/share.spec.js b/api/server/routes/__tests__/share.spec.js index 3c8b976d36..eb58ae764a 100644 --- a/api/server/routes/__tests__/share.spec.js +++ b/api/server/routes/__tests__/share.spec.js @@ -367,10 +367,25 @@ describe('share routes', () => { .send({ targetMessageId: 'msg-123' }); expect(response.status).toBe(200); - expect(applyForcedRetention).toHaveBeenCalledWith( - { userId: 'user-123', interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } }, - { conversationId: 'convo-123' }, - expect.objectContaining({ context: expect.any(String) }), + expect(applyForcedRetention).toHaveBeenCalledWith('convo-123', 'user-123', { + retentionMode: RetentionMode.EPHEMERAL, + }); + expect(applyForcedRetention).toHaveBeenCalledTimes(2); + expect(createSharedLink).toHaveBeenCalledWith( + 'user-123', + 'convo-123', + 'msg-123', + undefined, + true, + ); + expect(mockGrantCreationPermissions).toHaveBeenCalledWith( + 'link-123', + 'user-123', + true, + undefined, + ); + expect(applyForcedRetention.mock.invocationCallOrder[1]).toBeGreaterThan( + mockGrantCreationPermissions.mock.invocationCallOrder[0], ); }); @@ -388,11 +403,9 @@ describe('share routes', () => { * active share on retry) must still convert the touched conversation, otherwise a live * share could outlast a source chat that never converts. */ - expect(applyForcedRetention).toHaveBeenCalledWith( - { userId: 'user-123', interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } }, - { conversationId: 'convo-123' }, - expect.objectContaining({ context: expect.any(String) }), - ); + expect(applyForcedRetention).toHaveBeenCalledWith('convo-123', 'user-123', { + retentionMode: RetentionMode.EPHEMERAL, + }); expect(applyForcedRetention.mock.invocationCallOrder[0]).toBeLessThan( createSharedLink.mock.invocationCallOrder[0], ); @@ -407,11 +420,17 @@ describe('share routes', () => { .patch('/api/share/share-123') .send({ snapshotFiles: false }); - expect(applyForcedRetention).toHaveBeenCalledWith( - { userId: 'user-123', interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } }, - { conversationId: 'convo-123' }, - expect.objectContaining({ context: expect.any(String) }), + expect(applyForcedRetention).toHaveBeenCalledWith('convo-123', 'user-123', { + retentionMode: RetentionMode.EPHEMERAL, + }); + expect(updateSharedLink).toHaveBeenCalledWith( + 'user-123', + 'share-123', + undefined, + undefined, + false, ); + expect(mockUpdateSharedLinkPermissionsExpiration).not.toHaveBeenCalled(); }); it('rejects new shares for expired conversations in all retention mode', async () => { diff --git a/api/server/routes/agents/__tests__/abort.spec.js b/api/server/routes/agents/__tests__/abort.spec.js index 84d02030c8..538e01b243 100644 --- a/api/server/routes/agents/__tests__/abort.spec.js +++ b/api/server/routes/agents/__tests__/abort.spec.js @@ -293,7 +293,6 @@ describe('Agent Abort Endpoint', () => { }), expect.objectContaining({ context: 'api/server/routes/agents/index.js - abort endpoint', - capExpiryToConversation: true, }), ); }); diff --git a/api/server/routes/agents/index.js b/api/server/routes/agents/index.js index bf897323a0..759d9c0177 100644 --- a/api/server/routes/agents/index.js +++ b/api/server/routes/agents/index.js @@ -400,10 +400,7 @@ router.post('/chat/abort', configMiddleware, async (req, res) => { interfaceConfig: req?.config?.interfaceConfig, }, responseMessage, - { - context: 'api/server/routes/agents/index.js - abort endpoint', - capExpiryToConversation: true, - }, + { context: 'api/server/routes/agents/index.js - abort endpoint' }, ); logger.debug(`[AgentStream] Saved partial response for: ${jobStreamId}`); } catch (saveError) { diff --git a/api/server/routes/messages.js b/api/server/routes/messages.js index 8c32ce3c3e..857cca2b19 100644 --- a/api/server/routes/messages.js +++ b/api/server/routes/messages.js @@ -26,12 +26,8 @@ router.use(requireJwtAuth); * Enforces forced (ephemeral) retention after a message-only update (edit/feedback), * which bypasses the saveMessage/saveConvo enforcement. No-op outside forced retention. */ -const enforceForcedRetention = (req, conversationId, messageId, context) => - db.applyForcedRetention( - { userId: req?.user?.id, interfaceConfig: req?.config?.interfaceConfig }, - { conversationId, messageId }, - { context, capExpiryToConversation: true }, - ); +const enforceForcedRetention = (req, conversationId) => + db.applyForcedRetention(conversationId, req?.user?.id, req?.config?.interfaceConfig); router.get('/', async (req, res) => { try { @@ -193,7 +189,7 @@ router.post('/branch', configMiddleware, async (req, res) => { interfaceConfig: req?.config?.interfaceConfig, }, newMessage, - { context: 'POST /api/messages/branch', capExpiryToConversation: true }, + { context: 'POST /api/messages/branch' }, ); if (!savedMessage) { @@ -274,7 +270,7 @@ router.post('/artifact/:messageId', configMiddleware, async (req, res) => { content: message.content, user: req.user.id, }, - { context: 'POST /api/messages/artifact/:messageId', capExpiryToConversation: true }, + { context: 'POST /api/messages/artifact/:messageId' }, ); res.status(200).json({ @@ -387,12 +383,7 @@ router.put( ); const tokenCount = await countTokens(textToCount, model); const result = await db.updateMessage(req?.user?.id, { messageId, text, tokenCount }); - await enforceForcedRetention( - req, - conversationId, - messageId, - 'PUT /api/messages - edit text', - ); + await enforceForcedRetention(req, conversationId); return res.status(200).json(result); } @@ -437,12 +428,7 @@ router.put( content: updatedContent, tokenCount, }); - await enforceForcedRetention( - req, - conversationId, - messageId, - 'PUT /api/messages - edit content', - ); + await enforceForcedRetention(req, conversationId); return res.status(200).json(result); } catch (error) { logger.error('Error updating message:', error); @@ -468,7 +454,7 @@ router.put( }, { context: 'updateFeedback' }, ); - await enforceForcedRetention(req, conversationId, messageId, 'PUT /api/messages - feedback'); + await enforceForcedRetention(req, conversationId); // Best-effort: Assistants messages do not have deterministic AgentRun traces. if (!isAssistantsEndpoint(updatedMessage.endpoint)) { diff --git a/api/server/routes/share.js b/api/server/routes/share.js index 5177d1cad2..ce2353cb08 100644 --- a/api/server/routes/share.js +++ b/api/server/routes/share.js @@ -21,7 +21,12 @@ const { SYSTEM_TENANT_ID, createTempChatExpirationDate, } = require('@librechat/data-schemas'); -const { FileSources, PermissionTypes, Permissions } = require('librechat-data-provider'); +const { + FileSources, + PermissionTypes, + Permissions, + RetentionMode, +} = require('librechat-data-provider'); const { getFiles, updateFile, @@ -74,12 +79,8 @@ const resolveSharedLinkExpiration = (req, conversationId) => * retention, so sharing an older permanent chat does not leave it visible and non-expiring * after the public link itself expires; a no-op outside forced retention. */ -const enforceForcedRetention = (req, conversationId, context) => - applyForcedRetention( - { userId: req?.user?.id, interfaceConfig: req?.config?.interfaceConfig }, - { conversationId }, - { context }, - ); +const enforceForcedRetention = (req, conversationId) => + applyForcedRetention(conversationId, req?.user?.id, req?.config?.interfaceConfig); /** * Shared messages @@ -474,15 +475,15 @@ router.post( * cascade again. Converting first also lets the share expiration below read the * converted conversation's deadline. */ - await enforceForcedRetention( - req, - req.params.conversationId, - 'POST /api/share/:conversationId', - ); + await enforceForcedRetention(req, req.params.conversationId); const expiredAt = await resolveSharedLinkExpiration(req, req.params.conversationId); if (expiredAt != null && !isActiveExpirationDate(expiredAt)) { return res.status(404).end(); } + const writeExpiredAt = + req.config?.interfaceConfig?.retentionMode === RetentionMode.EPHEMERAL + ? undefined + : expiredAt; const role = await getRoleByName(req.user.role); const sharedLinksPerms = role?.permissions?.[PermissionTypes.SHARED_LINKS] || {}; @@ -495,11 +496,12 @@ router.post( req.user.id, req.params.conversationId, targetMessageId, - expiredAt, + writeExpiredAt, snapshotFiles, ); if (created) { - await grantCreationPermissions(created._id, req.user.id, grantPublic, expiredAt); + await grantCreationPermissions(created._id, req.user.id, grantPublic, writeExpiredAt); + await enforceForcedRetention(req, req.params.conversationId); res.status(200).json(created); } else { res.status(404).end(); @@ -530,20 +532,24 @@ router.patch('/:shareId', requireJwtAuth, configMiddleware, async (req, res) => if (expiredAt != null && !isActiveExpirationDate(expiredAt)) { return res.status(404).end(); } + const writeExpiredAt = + req.config?.interfaceConfig?.retentionMode === RetentionMode.EPHEMERAL + ? undefined + : expiredAt; const updatedShare = await updateSharedLink( req.user.id, req.params.shareId, targetMessageId, - expiredAt, + writeExpiredAt, isFileSnapshotEnabled(req.config) && req.body?.snapshotFiles !== false, ); if (updatedShare) { - if (updatedShare._id && expiredAt !== undefined) { - await updateSharedLinkPermissionsExpiration(updatedShare._id, expiredAt); + if (updatedShare._id && writeExpiredAt !== undefined) { + await updateSharedLinkPermissionsExpiration(updatedShare._id, writeExpiredAt); } if (existing?.conversationId) { - await enforceForcedRetention(req, existing.conversationId, 'PATCH /api/share/:shareId'); + await enforceForcedRetention(req, existing.conversationId); } res.status(200).json(updatedShare); } else { diff --git a/api/server/routes/tags.js b/api/server/routes/tags.js index 6fe79da8c5..f6ba87d76c 100644 --- a/api/server/routes/tags.js +++ b/api/server/routes/tags.js @@ -1,3 +1,4 @@ +const mongoose = require('mongoose'); const express = require('express'); const { logger } = require('@librechat/data-schemas'); const { generateCheckAccess } = require('@librechat/api'); @@ -9,7 +10,6 @@ const { deleteConversationTag, getConversationTags, applyForcedRetention, - applyForcedRetentionToTag, getRoleByName, } = require('~/models'); const { requireJwtAuth, configMiddleware } = require('~/server/middleware'); @@ -29,24 +29,25 @@ router.use(checkBookmarkAccess); * Enforces forced (ephemeral) retention after a bookmark-tag write converts an older * permanent conversation; a no-op outside forced retention. */ -const enforceForcedRetention = (req, conversationId, context) => - applyForcedRetention( - { userId: req?.user?.id, interfaceConfig: req?.config?.interfaceConfig }, - { conversationId }, - { context }, - ); +const enforceForcedRetention = (req, conversationId) => + applyForcedRetention(conversationId, req?.user?.id, req?.config?.interfaceConfig); /** * Enforces forced (ephemeral) retention on every conversation carrying a tag, for global * tag renames/deletes that rewrite conversation rows without converting them; a no-op * outside forced retention. */ -const enforceForcedRetentionForTag = (req, tag, context) => - applyForcedRetentionToTag( - { userId: req?.user?.id, interfaceConfig: req?.config?.interfaceConfig }, - { tag }, - { context }, +const enforceForcedRetentionForTag = async (req, tag) => { + const conversations = await mongoose.models.Conversation.find( + { user: req.user.id, tags: tag }, + 'conversationId', + ).lean(); + await Promise.all( + conversations.map(({ conversationId }) => + applyForcedRetention(conversationId, req.user.id, req?.config?.interfaceConfig), + ), ); +}; /** * GET / @@ -78,7 +79,7 @@ router.post('/', configMiddleware, async (req, res) => { try { const tag = await createConversationTag(req.user.id, req.body); if (req.body?.addToConversation && req.body?.conversationId) { - await enforceForcedRetention(req, req.body.conversationId, 'POST /api/tags'); + await enforceForcedRetention(req, req.body.conversationId); } res.status(200).json(tag); } catch (error) { @@ -151,11 +152,7 @@ router.put('/convo/:conversationId', configMiddleware, async (req, res) => { req.params.conversationId, req.body.tags, ); - await enforceForcedRetention( - req, - req.params.conversationId, - 'PUT /api/tags/convo/:conversationId', - ); + await enforceForcedRetention(req, req.params.conversationId); res.status(200).json(conversationTags); } catch (error) { logger.error('Error updating conversation tags', error); diff --git a/api/server/utils/import/fork.spec.js b/api/server/utils/import/fork.spec.js index 364c39f78b..4a08f9bd1e 100644 --- a/api/server/utils/import/fork.spec.js +++ b/api/server/utils/import/fork.spec.js @@ -1,6 +1,7 @@ const { Constants, ForkOptions, RetentionMode } = require('librechat-data-provider'); jest.mock('~/models', () => ({ + applyForcedRetention: jest.fn().mockResolvedValue(null), getConvo: jest.fn(), bulkSaveConvos: jest.fn(), getMessages: jest.fn(), @@ -37,6 +38,7 @@ const { cloneMessagesWithTimestamps, } = require('./fork'); const { + applyForcedRetention, bulkIncrementTagCounts, getConvo, bulkSaveConvos, @@ -116,17 +118,10 @@ describe('forkConversation', () => { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL, temporaryChatRetention: 1 }, }); - expect(bulkSaveConvos).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ isTemporary: true, expiredAt: expect.any(Date) }), - ]), - ); - expect(bulkSaveMessages).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ isTemporary: true, expiredAt: expect.any(Date) }), - ]), - true, - ); + expect(applyForcedRetention).toHaveBeenCalledWith(expect.any(String), 'user1', { + retentionMode: RetentionMode.EPHEMERAL, + temporaryChatRetention: 1, + }); }); test('should fork conversation without branches', async () => { @@ -293,17 +288,10 @@ describe('duplicateConversation', () => { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL, temporaryChatRetention: 1 }, }); - expect(bulkSaveConvos).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ isTemporary: true, expiredAt: expect.any(Date) }), - ]), - ); - expect(bulkSaveMessages).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ isTemporary: true, expiredAt: expect.any(Date) }), - ]), - true, - ); + expect(applyForcedRetention).toHaveBeenCalledWith(expect.any(String), 'user1', { + retentionMode: RetentionMode.EPHEMERAL, + temporaryChatRetention: 1, + }); }); test('should duplicate conversation and increment tag counts', async () => { diff --git a/api/server/utils/import/importBatchBuilder.js b/api/server/utils/import/importBatchBuilder.js index c382888b82..0ad07a8d65 100644 --- a/api/server/utils/import/importBatchBuilder.js +++ b/api/server/utils/import/importBatchBuilder.js @@ -7,11 +7,16 @@ const { const { EModelEndpoint, Constants, + RetentionMode, openAISettings, isAllDataRetention, - isForcedTemporaryRetention, } = require('librechat-data-provider'); -const { bulkIncrementTagCounts, bulkSaveConvos, bulkSaveMessages } = require('~/models'); +const { + applyForcedRetention, + bulkIncrementTagCounts, + bulkSaveConvos, + bulkSaveMessages, +} = require('~/models'); const { FALLBACK_MODEL_BY_ENDPOINT } = require('./defaults'); /** @@ -50,16 +55,19 @@ class ImportBatchBuilder { this.retentionFields = {}; return this.retentionFields; } + if (this.interfaceConfig?.retentionMode === RetentionMode.EPHEMERAL) { + this.retentionFields = {}; + return this.retentionFields; + } - const isTemporary = isForcedTemporaryRetention(this.interfaceConfig?.retentionMode); try { this.retentionFields = { - isTemporary, + isTemporary: false, expiredAt: createTempChatExpirationDate(this.interfaceConfig), }; } catch (error) { logger.error('[ImportBatchBuilder] Error creating import expiration date:', error); - this.retentionFields = { isTemporary, expiredAt: createFallbackRetentionDate() }; + this.retentionFields = { isTemporary: false, expiredAt: createFallbackRetentionDate() }; } return this.retentionFields; } @@ -152,6 +160,11 @@ class ImportBatchBuilder { ), ); await Promise.all(promises); + await Promise.all( + this.conversations.map(({ conversationId }) => + applyForcedRetention(conversationId, this.requestUserId, this.interfaceConfig), + ), + ); logger.debug( `user: ${this.requestUserId} | Added ${this.conversations.length} conversations and ${this.messages.length} messages to the DB.`, ); diff --git a/api/server/utils/import/importers-timestamp.spec.js b/api/server/utils/import/importers-timestamp.spec.js index 268cc74c0d..02444f00ac 100644 --- a/api/server/utils/import/importers-timestamp.spec.js +++ b/api/server/utils/import/importers-timestamp.spec.js @@ -5,6 +5,7 @@ const { getImporter } = require('./importers'); // Mock the database methods jest.mock('~/models', () => ({ + applyForcedRetention: jest.fn().mockResolvedValue(null), bulkSaveConvos: jest.fn(), bulkSaveMessages: jest.fn(), bulkIncrementTagCounts: jest.fn(), diff --git a/api/server/utils/import/importers.spec.js b/api/server/utils/import/importers.spec.js index 46e87889e0..a38457c8ef 100644 --- a/api/server/utils/import/importers.spec.js +++ b/api/server/utils/import/importers.spec.js @@ -9,7 +9,11 @@ const { } = require('librechat-data-provider'); const { getImporter, processAssistantMessage } = require('./importers'); const { ImportBatchBuilder } = require('./importBatchBuilder'); -const { bulkSaveMessages, bulkSaveConvos: _bulkSaveConvos } = require('~/models'); +const { + applyForcedRetention, + bulkSaveMessages, + bulkSaveConvos: _bulkSaveConvos, +} = require('~/models'); const mockGetEndpointsConfig = jest.fn().mockResolvedValue({ [EModelEndpoint.openAI]: { userProvide: false }, @@ -27,6 +31,7 @@ jest.mock('~/server/controllers/ModelController', () => ({ // Mock the database methods jest.mock('~/models', () => ({ + applyForcedRetention: jest.fn().mockResolvedValue(null), bulkSaveConvos: jest.fn(), bulkSaveMessages: jest.fn(), bulkIncrementTagCounts: jest.fn(), @@ -1146,7 +1151,7 @@ describe('importLibreChatConvo', () => { expect(result.conversation.expiredAt).toBe(message.expiredAt); }); - it('marks imported conversations and messages temporary under ephemeral retention', () => { + it('routes ephemeral imports through the forced-retention chokepoint', async () => { const requestUserId = 'user-123'; const builder = new ImportBatchBuilder(requestUserId, { retentionMode: RetentionMode.EPHEMERAL, @@ -1156,11 +1161,17 @@ describe('importLibreChatConvo', () => { const message = builder.addUserMessage('Ephemeral import'); const result = builder.finishConversation('Imported ephemeral chat'); - expect(message.isTemporary).toBe(true); - expect(message.expiredAt).toBeInstanceOf(Date); - expect(result.conversation.isTemporary).toBe(true); - expect(result.conversation.expiredAt).toBeInstanceOf(Date); - expect(result.conversation.expiredAt).toBe(message.expiredAt); + await builder.saveBatch(); + + expect(message.isTemporary).toBeUndefined(); + expect(message.expiredAt).toBeUndefined(); + expect(result.conversation.isTemporary).toBeUndefined(); + expect(result.conversation.expiredAt).toBeUndefined(); + expect(applyForcedRetention).toHaveBeenCalledWith( + result.conversation.conversationId, + requestUserId, + { retentionMode: RetentionMode.EPHEMERAL, temporaryChatRetention: 24 }, + ); }); }); }); diff --git a/config/migrate-ephemeral-retention.js b/config/migrate-ephemeral-retention.js deleted file mode 100644 index 6ca0c8cc5e..0000000000 --- a/config/migrate-ephemeral-retention.js +++ /dev/null @@ -1,240 +0,0 @@ -const path = require('path'); -const { - logger, - runAsSystem, - tenantStorage, - createTempChatExpirationDate, - forcedRetentionGapFilter, - sweepForcedRetention, - BASE_CONFIG_PRINCIPAL_ID, -} = require('@librechat/data-schemas'); -const { RetentionMode } = require('librechat-data-provider'); - -require('module-alias')({ base: path.resolve(__dirname, '..', 'api') }); -const connect = require('./connect'); - -const { getAppConfig } = require('~/server/services/Config'); -const { Conversation, Message, SharedLink, File, Config } = require('~/db/models'); -const { refreshChatProjectStats } = require('~/models'); - -const RETENTION_OVERRIDE_PATHS = ['interface.retentionMode', 'interface.temporaryChatRetention']; - -const overridesTouchRetention = (overrides) => { - const interfaceOverrides = overrides?.interface; - if (interfaceOverrides == null || typeof interfaceOverrides !== 'object') { - return false; - } - return 'retentionMode' in interfaceOverrides || 'temporaryChatRetention' in interfaceOverrides; -}; - -const tombstonesTouchRetention = (tombstones) => - (tombstones ?? []).some( - (tombstone) => tombstone === 'interface' || RETENTION_OVERRIDE_PATHS.includes(tombstone), - ); - -/** - * Finds active principal-scoped (role/user/group) config overrides that change retention - * behavior. The sweep evaluates one config per tenant, so a principal whose effective - * retention differs from the tenant default would be skipped or swept with the wrong - * deadline; such tenants are refused instead of migrated incorrectly. - */ -async function findPrincipalRetentionOverrides() { - const configs = await Config.find({ - isActive: true, - principalId: { $ne: BASE_CONFIG_PRINCIPAL_ID }, - }) - .select('principalType principalId overrides tombstones') - .lean(); - - return configs - .filter( - (config) => - overridesTouchRetention(config.overrides) || tombstonesTouchRetention(config.tombstones), - ) - .map((config) => `${config.principalType}:${config.principalId}`); -} - -/** - * Converts one tenant's pre-existing data to the forced (ephemeral) window using that tenant's - * own retention config. Runs inside the caller's tenant context, so every query is scoped to the - * tenant (the untenanted bucket runs in the system context and touches the untenanted rows). A - * tenant whose resolved config is not ephemeral is skipped unless `force` is set. - */ -async function sweepTenant({ tenantId, dryRun, force }) { - const label = tenantId ?? 'default'; - const appConfig = await getAppConfig(tenantId ? { tenantId } : undefined); - const interfaceConfig = appConfig?.interfaceConfig; - const retentionMode = interfaceConfig?.retentionMode; - - if (retentionMode !== RetentionMode.EPHEMERAL && !force) { - logger.info( - `[tenant ${label}] retentionMode is "${retentionMode ?? 'unset'}", not "ephemeral" — skipping.`, - ); - return { tenantId: tenantId ?? null, skipped: true, retentionMode }; - } - - const principalOverrides = await findPrincipalRetentionOverrides(); - if (principalOverrides.length > 0 && !force) { - logger.error( - `[tenant ${label}] ${principalOverrides.length} principal-scoped config override(s) change ` + - `retention behavior (${principalOverrides.join(', ')}). The migration evaluates one ` + - 'config per tenant, so it cannot safely convert this tenant; remove the overrides or ' + - 'pass --force to sweep with the tenant-level config anyway.', - ); - return { - tenantId: tenantId ?? null, - skipped: true, - retentionMode, - reason: 'principal-scoped retention overrides', - principalOverrides, - }; - } - - const forcedExpiredAt = createTempChatExpirationDate(interfaceConfig); - const nonConforming = await Conversation.countDocuments( - forcedRetentionGapFilter(forcedExpiredAt), - ); - logger.info(`[tenant ${label}] Found ${nonConforming} non-conforming conversation(s)`, { - forcedExpiredAt, - }); - - if (dryRun) { - return { tenantId: tenantId ?? null, dryRun: true, nonConforming, forcedExpiredAt }; - } - - const { projects, ...counts } = await sweepForcedRetention( - Conversation, - Message, - SharedLink, - File, - forcedExpiredAt, - ); - - /** - * Converted conversations are hidden from project views (isTemporary: true), so recompute the - * cached stats of every project that owned one; otherwise conversationCount and - * lastConversationId keep pointing at chats the project workspace no longer shows. - */ - let projectsRefreshed = 0; - for (const { user, chatProjectId } of projects) { - try { - await refreshChatProjectStats(user, chatProjectId); - projectsRefreshed += 1; - } catch (error) { - logger.error(`[tenant ${label}] Failed to refresh project ${chatProjectId} stats`, error); - } - } - - const result = { ...counts, projectsRefreshed }; - logger.info(`[tenant ${label}] completed`, result); - return { tenantId: tenantId ?? null, forcedExpiredAt, ...result }; -} - -/** - * Backfills forced (ephemeral) retention over conversations that predate the mode. - * - * Convert-on-touch only converts chats that are subsequently written, so enabling ephemeral - * retention on a deployment with existing data leaves untouched permanent chats visible and - * non-expiring. This sweep converts every non-conforming conversation, its messages, its - * shares, and its uploaded files to the forced window (capping rather than extending sooner - * deadlines). It is idempotent and safe to re-run. - * - * Each tenant is converted with its OWN retention config: tenants are enumerated and swept inside - * their tenant context (so queries are scoped to that tenant), and a tenant whose config is not - * ephemeral is skipped. This prevents a system/default config from force-expiring a tenant that - * never enabled ephemeral retention. In a mixed deployment, rows without a tenantId cannot be - * scoped to a tenant config, so they are left untouched and must be converted from a - * single-tenant context. - */ -async function migrateEphemeralRetention({ dryRun = true, force = false } = {}) { - await connect(); - - return runAsSystem(async () => { - logger.info('Starting Ephemeral Retention Migration', { dryRun, force }); - - const tenantIds = await Conversation.distinct('tenantId'); - const realTenants = tenantIds.filter((tenantId) => tenantId != null && tenantId !== ''); - /** - * `distinct` only enumerates stored values, so rows missing the tenantId field entirely - * contribute nothing to it. Count them directly ({ tenantId: null } matches both explicit - * null and missing fields) so pre-tenancy rows in a mixed deployment surface a warning - * instead of being silently skipped. - */ - const untenantedCount = await Conversation.countDocuments({ - $or: [{ tenantId: null }, { tenantId: '' }], - }); - const hasUntenanted = untenantedCount > 0; - const skippedUntenanted = realTenants.length > 0 && hasUntenanted; - - const tenants = realTenants.length > 0 ? realTenants : [undefined]; - if (skippedUntenanted) { - logger.warn( - `${untenantedCount} conversation(s) have no tenantId; they cannot be scoped to a tenant ` + - 'config and are skipped. Re-run in a single-tenant context to convert them.', - ); - } - - const results = []; - for (const tenantId of tenants) { - const result = tenantId - ? await tenantStorage.run({ tenantId }, async () => - sweepTenant({ tenantId, dryRun, force }), - ) - : await sweepTenant({ tenantId, dryRun, force }); - results.push(result); - } - - return { dryRun, skippedUntenanted, tenants: results }; - }); -} - -if (require.main === module) { - const dryRun = process.argv.includes('--dry-run'); - const force = process.argv.includes('--force'); - - migrateEphemeralRetention({ dryRun, force }) - .then((result) => { - if (result.skippedUntenanted) { - console.log('\nNote: conversations without a tenantId were skipped (see log warning).'); - } - - if (result.tenants.length > 0 && result.tenants.every((tenant) => tenant.skipped)) { - console.log('\n=== NOTHING TO MIGRATE ==='); - for (const tenant of result.tenants) { - const label = tenant.tenantId ?? 'default'; - const reason = tenant.reason ?? `retentionMode: ${tenant.retentionMode ?? 'unset'}`; - console.log(`[${label}] skipped (${reason})`); - } - console.log('Enable ephemeral retention, or pass --force to run anyway.'); - process.exit(1); - } - - if (result.dryRun) { - console.log('\n=== DRY RUN RESULTS ==='); - for (const tenant of result.tenants) { - const label = tenant.tenantId ?? 'default'; - if (tenant.skipped) { - const reason = tenant.reason ?? `retentionMode: ${tenant.retentionMode ?? 'unset'}`; - console.log(`[${label}] skipped (${reason})`); - continue; - } - const expiry = tenant.forcedExpiredAt; - console.log( - `[${label}] non-conforming conversations: ${tenant.nonConforming} ` + - `(forced expiry: ${expiry?.toISOString?.() ?? expiry})`, - ); - } - console.log('\nTo run the actual migration, remove the --dry-run flag.'); - } else { - console.log('\n=== MIGRATION RESULTS ==='); - console.log(JSON.stringify(result, null, 2)); - } - process.exit(0); - }) - .catch((error) => { - console.error('Ephemeral retention migration failed:', error); - process.exit(1); - }); -} - -module.exports = { migrateEphemeralRetention }; diff --git a/package.json b/package.json index 996e579337..74b0702cd6 100644 --- a/package.json +++ b/package.json @@ -109,9 +109,7 @@ "migrate:shared-link-permissions:batch": "node config/migrate-shared-link-permissions.js --batch-size=50", "migrate:orphaned-agent-files:dry-run": "node config/migrate-orphaned-agent-files.js --dry-run", "migrate:orphaned-agent-files": "node config/migrate-orphaned-agent-files.js", - "migrate:orphaned-agent-files:batch": "node config/migrate-orphaned-agent-files.js --batch-size=50", - "migrate:ephemeral-retention:dry-run": "node config/migrate-ephemeral-retention.js --dry-run", - "migrate:ephemeral-retention": "node config/migrate-ephemeral-retention.js" + "migrate:orphaned-agent-files:batch": "node config/migrate-orphaned-agent-files.js --batch-size=50" }, "repository": { "type": "git", diff --git a/packages/api/src/app/permissions.spec.ts b/packages/api/src/app/permissions.spec.ts index a888d9bd6f..b5aa6e6e8b 100644 --- a/packages/api/src/app/permissions.spec.ts +++ b/packages/api/src/app/permissions.spec.ts @@ -234,6 +234,33 @@ describe('updateInterfacePermissions - permissions', () => { ); }); + it('preserves customized TEMPORARY_CHAT role permissions during ephemeral mode', async () => { + const config = { + interface: { + retentionMode: RetentionMode.EPHEMERAL, + }, + }; + const configDefaults = { interface: {} } as TConfigDefaults; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); + const appConfig = { config, interfaceConfig } as unknown as AppConfig; + mockGetRoleByName.mockResolvedValue({ + permissions: { + [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: false }, + }, + }); + + await updateInterfacePermissions({ + appConfig, + getRoleByName: mockGetRoleByName, + updateAccessPermissions: mockUpdateAccessPermissions, + }); + + expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2); + for (const [, permissionsUpdate] of mockUpdateAccessPermissions.mock.calls) { + expect(permissionsUpdate).not.toHaveProperty(PermissionTypes.TEMPORARY_CHAT); + } + }); + it('should call updateAccessPermissions with false when permission types are false', async () => { const config = { interface: { diff --git a/packages/api/src/app/permissions.ts b/packages/api/src/app/permissions.ts index 3c9c127bce..25e73c6e17 100644 --- a/packages/api/src/app/permissions.ts +++ b/packages/api/src/app/permissions.ts @@ -29,10 +29,7 @@ function hasExplicitConfig( case PermissionTypes.AGENTS: return interfaceConfig?.agents !== undefined; case PermissionTypes.TEMPORARY_CHAT: - return ( - interfaceConfig?.temporaryChat !== undefined || - isForcedTemporaryRetention(interfaceConfig?.retentionMode) - ); + return interfaceConfig?.temporaryChat !== undefined; case PermissionTypes.RUN_CODE: return interfaceConfig?.runCode !== undefined; case PermissionTypes.WEB_SEARCH: diff --git a/packages/api/src/files/retention.spec.ts b/packages/api/src/files/retention.spec.ts index 604a60d204..60b65a6a0d 100644 --- a/packages/api/src/files/retention.spec.ts +++ b/packages/api/src/files/retention.spec.ts @@ -52,41 +52,14 @@ describe('retention helpers', () => { expect(dependencies.getConvo).not.toHaveBeenCalled(); }); - it('returns a fresh expiry when retentionMode is EPHEMERAL and the conversation has no earlier deadline', async () => { - dependencies.getConvo.mockResolvedValue(null); - - const result = await getRetentionExpiry( - request({ config: { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } } }), - dependencies, - ); - - expect(result).toEqual({ expiredAt: expirationDate }); - expect(dependencies.getConvo).toHaveBeenCalledWith('user-1', 'convo-1'); - }); - - it('caps an ephemeral file to a parent conversation that expires sooner', async () => { - const soonerExpiry = new Date('2029-01-01T00:00:00.000Z'); - dependencies.getConvo.mockResolvedValue({ expiredAt: soonerExpiry }); - - const result = await getRetentionExpiry( - request({ config: { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } } }), - dependencies, - ); - - expect(result).toEqual({ expiredAt: soonerExpiry }); - }); - - it('keeps the fresh window when an ephemeral parent expires later', async () => { - dependencies.getConvo.mockResolvedValue({ - expiredAt: new Date('2031-01-01T00:00:00.000Z'), - }); - + it('returns a fresh initial expiry for retentionMode EPHEMERAL', async () => { const result = await getRetentionExpiry( request({ config: { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } } }), dependencies, ); expect(result).toEqual({ expiredAt: expirationDate }); + expect(dependencies.getConvo).not.toHaveBeenCalled(); }); it('returns a fresh expiry when the conversation has an active expiration', async () => { @@ -349,7 +322,7 @@ describe('retention helpers', () => { ); expect(result).toEqual({ expiredAt: expirationDate }); - expect(dependencies.getConvo).toHaveBeenCalledWith('user-1', 'convo-1'); + expect(dependencies.getConvo).not.toHaveBeenCalled(); expect(dependencies.createExpirationDate).toHaveBeenCalledTimes(1); }); @@ -469,27 +442,10 @@ describe('retention helpers', () => { expect(dependencies.createExpirationDate).not.toHaveBeenCalled(); }); - it('caps the share at an active source conversation expiration', async () => { + it('leaves forced-retention parent capping to the chokepoint', async () => { const conversationExpiredAt = new Date(Date.now() + 60 * 60 * 1000); dependencies.getConvo.mockResolvedValue({ expiredAt: conversationExpiredAt }); - await expect( - getSharedLinkExpiration( - { - req: request({ - config: { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } }, - }), - conversationId: 'convo-1', - }, - dependencies, - ), - ).resolves.toBe(conversationExpiredAt); - }); - - it('uses the fresh window when it expires before the source conversation', async () => { - const conversationExpiredAt = new Date('2031-01-01T00:00:00.000Z'); - dependencies.getConvo.mockResolvedValue({ expiredAt: conversationExpiredAt }); - await expect( getSharedLinkExpiration( { @@ -503,7 +459,7 @@ describe('retention helpers', () => { ).resolves.toBe(expirationDate); }); - it('falls back to the active source expiration when creating a window throws', async () => { + it('returns null when creating a share expiration throws', async () => { const conversationExpiredAt = new Date(Date.now() + 60 * 60 * 1000); dependencies.getConvo.mockResolvedValue({ expiredAt: conversationExpiredAt }); dependencies.createExpirationDate.mockImplementation(() => { @@ -520,7 +476,7 @@ describe('retention helpers', () => { }, dependencies, ), - ).resolves.toBe(conversationExpiredAt); + ).resolves.toBeNull(); }); }); }); diff --git a/packages/api/src/files/retention.ts b/packages/api/src/files/retention.ts index d4a74b6a28..b0373bf711 100644 --- a/packages/api/src/files/retention.ts +++ b/packages/api/src/files/retention.ts @@ -99,49 +99,11 @@ const getRetentionCacheKey = (req: RetentionRequest): string => String(req.body?.isTemporary ?? ''), ].join('|'); -/** - * Resolves a forced (ephemeral) file deadline. The file is always retained, but capped to the - * minimum of a freshly created window and the parent conversation's active expiry, so a new - * attachment cannot linger in files/storage after the conversation and messages are TTL-deleted. - */ -async function computeForcedRetentionExpiry( - req: RetentionRequest | null | undefined, - dependencies: RetentionDependencies, -): Promise { - const fresh = createRetentionExpiry(req, dependencies); - const conversationId = req?.body?.conversationId; - const userId = req?.user?.id; - if (!conversationId || !userId) { - return fresh; - } - - try { - const convo = await dependencies.getConvo(userId, conversationId); - const conversationExpiredAt = getConversationExpirationDate(convo); - if (conversationExpiredAt == null) { - return fresh; - } - if (!isActiveExpirationDate(conversationExpiredAt)) { - return { expiredAt: conversationExpiredAt }; - } - if (fresh.expiredAt != null && conversationExpiredAt < fresh.expiredAt) { - return { expiredAt: conversationExpiredAt }; - } - return fresh; - } catch (err) { - dependencies.logger?.error('[getRetentionExpiry] Error checking conversation retention:', err); - return fresh; - } -} - async function computeRetentionExpiry( req: RetentionRequest | null | undefined, dependencies: RetentionDependencies, ): Promise { const retentionMode = req?.config?.interfaceConfig?.retentionMode; - if (retentionMode === RetentionMode.EPHEMERAL) { - return computeForcedRetentionExpiry(req, dependencies); - } if (isAllDataRetention(retentionMode)) { return createRetentionExpiry(req, dependencies); } @@ -243,10 +205,8 @@ export async function getAgentFileRetentionExpiry( * - `null`: the share should be stored without an expiration. * - `Date`: the share should expire at that date; callers reject already-expired dates. * - * A share embeds a snapshot of the source conversation's messages, so it must never - * outlive the conversation it was created from. When the source conversation still has an - * active expiration, the share is capped at the earlier of that deadline and a freshly - * created retention window rather than starting a brand-new window. + * Forced-retention parent/child alignment is deliberately not implemented here: + * `applyForcedRetention` owns that invariant after the share and its ACL entries exist. */ export async function getSharedLinkExpiration( { @@ -279,14 +239,10 @@ export async function getSharedLinkExpiration( } try { - const createdExpiration = dependencies.createExpirationDate(req?.config?.interfaceConfig); - if (conversationExpiredAt != null && conversationExpiredAt < createdExpiration) { - return conversationExpiredAt; - } - return createdExpiration; + return dependencies.createExpirationDate(req?.config?.interfaceConfig); } catch (err) { dependencies.logger?.error('[getSharedLinkExpiration] Error creating expiration date:', err); - return conversationExpiredAt ?? null; + return null; } } diff --git a/packages/data-schemas/src/methods/chatProject.spec.ts b/packages/data-schemas/src/methods/chatProject.spec.ts index faa818f050..edd489bb3d 100644 --- a/packages/data-schemas/src/methods/chatProject.spec.ts +++ b/packages/data-schemas/src/methods/chatProject.spec.ts @@ -3,8 +3,14 @@ import { v4 as uuidv4 } from 'uuid'; import { RetentionMode } from 'librechat-data-provider'; import { MongoMemoryServer } from 'mongodb-memory-server'; import type { IChatProject, IConversation, IMessage, IMongoFile, ISharedLink } from '~/types'; -import { createChatProjectMethods, type ChatProjectMethods } from './chatProject'; +import { + createChatProjectMethods, + refreshChatProjectStatsForUser, + type ChatProjectMethods, +} from './chatProject'; +import { createApplyForcedRetention } from '~/utils/retention'; import { createModels } from '~/models'; +import logger from '~/config/winston'; const ephemeralConfig = { temporaryChatRetention: 24, @@ -40,7 +46,12 @@ beforeAll(async () => { Message = mongoose.models.Message as mongoose.Model; SharedLink = mongoose.models.SharedLink as mongoose.Model; File = mongoose.models.File as mongoose.Model; - methods = createChatProjectMethods(mongoose); + const applyForcedRetention = createApplyForcedRetention(mongoose, { + logger, + refreshProjectStats: (userId, projectId) => + refreshChatProjectStatsForUser(mongoose, userId, projectId), + }); + methods = createChatProjectMethods(mongoose, applyForcedRetention); await mongoose.connect(mongoUri); }); diff --git a/packages/data-schemas/src/methods/chatProject.ts b/packages/data-schemas/src/methods/chatProject.ts index e38f1587f7..7793b9f612 100644 --- a/packages/data-schemas/src/methods/chatProject.ts +++ b/packages/data-schemas/src/methods/chatProject.ts @@ -1,20 +1,7 @@ -import { isForcedTemporaryRetention } from 'librechat-data-provider'; import type { FilterQuery, Model, SortOrder, Types } from 'mongoose'; -import type { - AppConfig, - IChatProject, - IChatProjectDocument, - IConversation, - IMessage, - IMongoFile, - ISharedLink, -} from '~/types'; -import { - buildRetentionVisibilityFilter, - cascadeForcedConversationRetention, - cascadeForcedRetentionByProject, - resolveForcedRetentionDate, -} from '~/utils/retention'; +import type { AppConfig, IChatProject, IChatProjectDocument, IConversation } from '~/types'; +import type { ApplyForcedRetention } from '~/utils/retention'; +import { buildRetentionVisibilityFilter } from '~/utils/retention'; import { isValidObjectIdString } from '~/utils/objectId'; import { escapeRegExp } from '~/utils/string'; import logger from '~/config/winston'; @@ -270,59 +257,10 @@ export async function updateChatProjectLastConversationForUser( await ChatProject.updateOne({ _id: new mongoose.Types.ObjectId(projectId), user }, update); } -export function createChatProjectMethods(mongoose: typeof import('mongoose')): ChatProjectMethods { - /** - * Converts a project's conversations to the forced (ephemeral) window when the deployment runs - * in ephemeral mode. Assigning, removing, or bulk-unassigning a chat rewrites its row without - * setting `isTemporary`/`expiredAt`, so a permanent chat organized after the install switched - * to ephemeral would otherwise stay visible and never expire. A no-op outside forced retention. - */ - function forceProjectConversationRetention( - user: string, - chatProjectId: string, - interfaceConfig?: AppConfig['interfaceConfig'], - ): Promise { - if (!isForcedTemporaryRetention(interfaceConfig?.retentionMode)) { - return Promise.resolve(); - } - const Conversation = mongoose.models.Conversation as Model; - const Message = mongoose.models.Message as Model; - const SharedLink = mongoose.models.SharedLink as Model; - const File = mongoose.models.File as Model; - return cascadeForcedRetentionByProject( - Conversation, - Message, - SharedLink, - File, - user, - chatProjectId, - resolveForcedRetentionDate(interfaceConfig), - ); - } - - async function forceConversationRetention( - user: string, - conversationId: string, - interfaceConfig?: AppConfig['interfaceConfig'], - ): Promise { - if (!isForcedTemporaryRetention(interfaceConfig?.retentionMode)) { - return; - } - const Conversation = mongoose.models.Conversation as Model; - const Message = mongoose.models.Message as Model; - const SharedLink = mongoose.models.SharedLink as Model; - const File = mongoose.models.File as Model; - await cascadeForcedConversationRetention( - Conversation, - Message, - SharedLink, - File, - user, - conversationId, - resolveForcedRetentionDate(interfaceConfig), - ); - } - +export function createChatProjectMethods( + mongoose: typeof import('mongoose'), + applyForcedRetention: ApplyForcedRetention, +): ChatProjectMethods { async function createChatProject( user: string, input: CreateChatProjectInput, @@ -445,7 +383,15 @@ export function createChatProjectMethods(mongoose: typeof import('mongoose')): C return { deletedCount: 0, modifiedCount: 0 }; } - await forceProjectConversationRetention(user, projectId, interfaceConfig); + const conversations = await Conversation.find( + { user, chatProjectId: projectId }, + 'conversationId', + ).lean>>(); + await Promise.all( + conversations.map(({ conversationId }) => + applyForcedRetention(conversationId, user, interfaceConfig), + ), + ); const [conversationResult, deleteResult] = await Promise.all([ Conversation.updateMany( @@ -499,7 +445,7 @@ export function createChatProjectMethods(mongoose: typeof import('mongoose')): C * isTemporary/expiredAt fields rather than a stale pre-conversion snapshot. A no-op outside * forced retention. */ - await forceConversationRetention(user, conversationId, interfaceConfig); + await applyForcedRetention(conversationId, user, interfaceConfig); const update = normalizedProjectId == null diff --git a/packages/data-schemas/src/methods/conversation.spec.ts b/packages/data-schemas/src/methods/conversation.spec.ts index 9f48f4dfe4..0c038d94e8 100644 --- a/packages/data-schemas/src/methods/conversation.spec.ts +++ b/packages/data-schemas/src/methods/conversation.spec.ts @@ -1,11 +1,21 @@ import mongoose from 'mongoose'; import { v4 as uuidv4 } from 'uuid'; import { MongoMemoryServer } from 'mongodb-memory-server'; -import { EModelEndpoint, RetentionMode } from 'librechat-data-provider'; -import type { IChatProject, IConversation, IMessage, IMongoFile, ISharedLink } from '../types'; +import { EModelEndpoint, ResourceType, RetentionMode } from 'librechat-data-provider'; +import type { + IAclEntry, + IChatProject, + IConversation, + IMessage, + IMongoFile, + ISharedLink, +} from '../types'; import { ConversationMethods, createConversationMethods } from './conversation'; import { tenantStorage, runAsSystem } from '~/config/tenantContext'; +import { refreshChatProjectStatsForUser } from './chatProject'; +import { createApplyForcedRetention } from '~/utils/retention'; import { createModels } from '../models'; +import logger from '~/config/winston'; jest.mock('~/config/winston', () => ({ error: jest.fn(), @@ -47,7 +57,16 @@ beforeAll(async () => { position: number; }>; - methods = createConversationMethods(mongoose, { getMessages, deleteMessages }); + const applyForcedRetention = createApplyForcedRetention(mongoose, { + logger, + refreshProjectStats: (userId, projectId) => + refreshChatProjectStatsForUser(mongoose, userId, projectId), + }); + methods = createConversationMethods( + mongoose, + { getMessages, deleteMessages }, + applyForcedRetention, + ); await mongoose.connect(mongoUri); }); @@ -894,22 +913,24 @@ describe('Conversation Operations', () => { it('caps existing files when ephemeral converts a permanent conversation', async () => { const conversationId = uuidv4(); const fileId = uuidv4(); + const ownerObjectId = new mongoose.Types.ObjectId(); + const owner = ownerObjectId.toString(); await Conversation.create({ conversationId, - user: 'user123', + user: owner, endpoint: EModelEndpoint.openAI, title: 'Existing permanent chat', }); await File().collection.insertOne({ file_id: fileId, conversationId, - user: new mongoose.Types.ObjectId(), + user: ownerObjectId, expiredAt: null, }); await saveConvo( { - userId: 'user123', + userId: owner, interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, }, { conversationId, isArchived: true }, @@ -924,33 +945,48 @@ describe('Conversation Operations', () => { const fileId = uuidv4(); const parentDeadline = new Date(Date.now() + 60 * 60 * 1000); const SharedLink = mongoose.models.SharedLink as mongoose.Model; + const AclEntry = mongoose.models.AclEntry as mongoose.Model; + const ownerObjectId = new mongoose.Types.ObjectId(); + const owner = ownerObjectId.toString(); await Conversation.create({ conversationId, - user: 'user123', + user: owner, endpoint: EModelEndpoint.openAI, title: 'Conforming temporary chat', isTemporary: true, expiredAt: parentDeadline, }); - await SharedLink.create({ conversationId, user: 'user123', shareId: uuidv4() }); + const share = await SharedLink.create({ + conversationId, + user: owner, + shareId: uuidv4(), + expiredAt: parentDeadline, + }); + await AclEntry.collection.insertOne({ + resourceType: ResourceType.SHARED_LINK, + resourceId: share._id, + expiredAt: null, + }); await File().collection.insertOne({ file_id: fileId, conversationId, - user: new mongoose.Types.ObjectId(), + user: ownerObjectId, expiredAt: null, }); await saveConvo( { - userId: 'user123', + userId: owner, interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, }, { conversationId, isArchived: true }, ); - const share = await SharedLink.findOne({ conversationId }).lean(); - expect(share?.expiredAt?.getTime()).toBe(parentDeadline.getTime()); + const reloadedShare = await SharedLink.findOne({ conversationId }).lean(); + expect(reloadedShare?.expiredAt?.getTime()).toBe(parentDeadline.getTime()); + const aclEntry = await AclEntry.findOne({ resourceId: reloadedShare?._id }).lean(); + expect(aclEntry?.expiredAt?.getTime()).toBe(parentDeadline.getTime()); const file = await File().findOne({ file_id: fileId }).lean(); expect(file?.expiredAt?.getTime()).toBe(parentDeadline.getTime()); @@ -959,22 +995,23 @@ describe('Conversation Operations', () => { expect(convo?.expiredAt?.getTime()).toBe(parentDeadline.getTime()); }); - it('keeps the chat convertible when a child backfill fails during forced conversion', async () => { + it('retries child alignment even when a failed backfill left the parent conforming', async () => { const conversationId = uuidv4(); + const owner = new mongoose.Types.ObjectId().toString(); await Conversation.create({ conversationId, - user: 'user123', + user: owner, endpoint: EModelEndpoint.openAI, title: 'Existing permanent chat', }); - await Message().create({ messageId: uuidv4(), conversationId, user: 'user123', text: 'hi' }); + await Message().create({ messageId: uuidv4(), conversationId, user: owner, text: 'hi' }); const spy = jest.spyOn(File(), 'updateMany').mockImplementationOnce(() => { throw new Error('file backfill failed'); }); const failed = await saveConvo( { - userId: 'user123', + userId: owner, interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, }, { conversationId, isArchived: true }, @@ -982,13 +1019,15 @@ describe('Conversation Operations', () => { spy.mockRestore(); expect(failed).toEqual({ message: 'Error saving conversation' }); - const stillPermanent = await Conversation.findOne({ conversationId }).lean(); - expect(stillPermanent?.isTemporary ?? null).not.toBe(true); - expect(stillPermanent?.expiredAt ?? null).toBeNull(); + const convertedParent = await Conversation.findOne({ + conversationId, + }).lean(); + expect(convertedParent?.isTemporary).toBe(true); + expect(convertedParent?.expiredAt).toBeInstanceOf(Date); await saveConvo( { - userId: 'user123', + userId: owner, interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, }, { conversationId, isArchived: true }, @@ -1003,6 +1042,74 @@ describe('Conversation Operations', () => { expect(message?.expiredAt).toBeInstanceOf(Date); }); + it('converges children on the next chokepoint call after a concurrent parent shortening', async () => { + const conversationId = uuidv4(); + const ownerObjectId = new mongoose.Types.ObjectId(); + const owner = ownerObjectId.toString(); + const soonerExpiry = new Date(Date.now() + 30 * 60 * 1000); + const messageId = uuidv4(); + const convo = await Conversation.create({ + conversationId, + user: owner, + endpoint: EModelEndpoint.openAI, + title: 'Concurrent retention chat', + }); + await Message().create({ messageId, conversationId, user: owner, text: 'child' }); + + const FileModel = File(); + const updateMany = FileModel.updateMany.bind(FileModel); + let shortened = false; + const racingUpdateMany = jest.fn( + async ( + filter: mongoose.FilterQuery, + update: mongoose.UpdateQuery, + ) => { + if (!shortened) { + shortened = true; + await Conversation.collection.updateOne( + { _id: convo._id }, + { $set: { isTemporary: true, expiredAt: soonerExpiry } }, + ); + } + return updateMany(filter, update); + }, + ); + Object.assign(FileModel, { updateMany: racingUpdateMany }); + await (async () => { + try { + return await saveConvo( + { + userId: owner, + interfaceConfig: { + temporaryChatRetention: 24, + retentionMode: RetentionMode.EPHEMERAL, + }, + }, + { conversationId, isArchived: true }, + ); + } finally { + Object.assign(FileModel, { updateMany }); + } + })(); + + const parent = await Conversation.findById(convo._id).lean(); + expect(parent?.expiredAt?.getTime()).toBe(soonerExpiry.getTime()); + + const converged = await saveConvo( + { + userId: owner, + interfaceConfig: { + temporaryChatRetention: 24, + retentionMode: RetentionMode.EPHEMERAL, + }, + }, + { conversationId, isArchived: true }, + ); + expect(converged?.expiredAt?.getTime()).toBe(soonerExpiry.getTime()); + const message = await Message().findOne({ messageId }).lean(); + expect(message?.expiredAt?.getTime()).toBe(soonerExpiry.getTime()); + }); + 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); @@ -1152,8 +1259,11 @@ describe('Conversation Operations', () => { it('caps an existing permanent shared link when ephemeral converts a conversation', async () => { const SharedLink = mongoose.models.SharedLink as mongoose.Model; + const AclEntry = mongoose.models.AclEntry as mongoose.Model; await SharedLink.deleteMany({}); + await AclEntry.deleteMany({}); const conversationId = uuidv4(); + const soonerAclExpiry = new Date(Date.now() + 30 * 60 * 1000); await Conversation.create({ conversationId, user: 'user123', @@ -1166,6 +1276,18 @@ describe('Conversation Operations', () => { shareId: uuidv4(), }); expect(share.expiredAt ?? null).toBeNull(); + await AclEntry.collection.insertMany([ + { + resourceType: ResourceType.SHARED_LINK, + resourceId: share._id, + expiredAt: null, + }, + { + resourceType: ResourceType.SHARED_LINK, + resourceId: share._id, + expiredAt: soonerAclExpiry, + }, + ]); await saveConvo( { @@ -1177,6 +1299,10 @@ describe('Conversation Operations', () => { const reloaded = await SharedLink.findOne({ conversationId }).lean(); expect(reloaded?.expiredAt).toBeInstanceOf(Date); + const entries = await AclEntry.find({ resourceId: share._id }).sort({ expiredAt: -1 }).lean(); + expect(entries).toHaveLength(2); + expect(entries[0].expiredAt?.getTime()).toBe(reloaded?.expiredAt?.getTime()); + expect(entries[1].expiredAt?.getTime()).toBe(soonerAclExpiry.getTime()); }); }); diff --git a/packages/data-schemas/src/methods/conversation.ts b/packages/data-schemas/src/methods/conversation.ts index 45cdf92782..6a9057fe7a 100644 --- a/packages/data-schemas/src/methods/conversation.ts +++ b/packages/data-schemas/src/methods/conversation.ts @@ -1,30 +1,14 @@ -import { RetentionMode, isForcedTemporaryRetention } from 'librechat-data-provider'; +import { RetentionMode } from 'librechat-data-provider'; import type { FilterQuery, Model, SortOrder } from 'mongoose'; import type { DeleteResult } from 'mongoose'; -import type { - AppConfig, - IChatProjectDocument, - IConversation, - IMessage, - IMongoFile, - ISharedLink, -} from '~/types'; +import type { AppConfig, IChatProjectDocument, IConversation } from '~/types'; +import type { ApplyForcedRetention } from '~/utils/retention'; import type { MessageMethods } from './message'; -import { - buildRetentionVisibilityFilter, - capConversationFiles, - capConversationSharedLinks, - capForcedRetentionExpiry, - collectConversationFileIds, - conversationNeedsForcedRetention, - conversationSeedFileIds, - createFallbackRetentionDate, - forceConversationMessagesTemporary, -} from '~/utils/retention'; import { refreshChatProjectStatsForUser, updateChatProjectLastConversationForUser, } from './chatProject'; +import { buildRetentionVisibilityFilter, createFallbackRetentionDate } from '~/utils/retention'; import { createTempChatExpirationDate } from '~/utils/tempChatRetention'; import { tenantSafeBulkWrite } from '~/utils/tenantBulkWrite'; import { isValidObjectIdString } from '~/utils/objectId'; @@ -87,6 +71,7 @@ export interface ConversationMethods { export function createConversationMethods( mongoose: typeof import('mongoose'), messageMethods?: Pick, + applyForcedRetention?: ApplyForcedRetention, ): ConversationMethods { function getMessageMethods() { if (!messageMethods) { @@ -253,30 +238,16 @@ export function createConversationMethods( } } - const isForcedRetention = isForcedTemporaryRetention(interfaceConfig?.retentionMode); const mayChangeProjectMembership = Object.prototype.hasOwnProperty.call(update, 'chatProjectId') || Object.prototype.hasOwnProperty.call(unsetFields, 'chatProjectId'); let previousChatProjectId: string | null = null; - let parentRetention: { - isTemporary?: boolean | null; - expiredAt?: Date | null; - files?: string[]; - file_ids?: string[]; - } | null = null; - if (mayChangeProjectMembership || isForcedRetention) { + if (mayChangeProjectMembership) { const existing = await Conversation.findOne( { conversationId, user: userId }, - 'chatProjectId isTemporary expiredAt files file_ids', - ).lean<{ - chatProjectId?: string | null; - isTemporary?: boolean | null; - expiredAt?: Date | null; - files?: string[]; - file_ids?: string[]; - } | null>(); + 'chatProjectId', + ).lean<{ chatProjectId?: string | null } | null>(); previousChatProjectId = existing?.chatProjectId ?? null; - parentRetention = existing; } if (newConversationId) { @@ -284,20 +255,10 @@ export function createConversationMethods( } if (interfaceConfig?.retentionMode === RetentionMode.EPHEMERAL) { - update.isTemporary = true; - try { - update.expiredAt = capForcedRetentionExpiry( - parentRetention?.expiredAt, - createTempChatExpirationDate(interfaceConfig), - ); - } catch (err) { - logger.error('Error creating temporary chat expiration date:', err); - logger.info(`---\`saveConvo\` context: ${metadata?.context}`); - update.expiredAt = capForcedRetentionExpiry( - parentRetention?.expiredAt, - createFallbackRetentionDate(), - ); - } + delete update.isTemporary; + delete update.expiredAt; + delete unsetFields.isTemporary; + delete unsetFields.expiredAt; } else if (interfaceConfig?.retentionMode === RetentionMode.ALL) { if (typeof isTemporary === 'boolean') { update.isTemporary = isTemporary; @@ -323,37 +284,6 @@ export function createConversationMethods( update.expiredAt = null; } - const forcedExpiredAt = update.expiredAt; - /** - * 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 && parentRetention != null) { - const Message = mongoose.models.Message as Model; - const SharedLink = mongoose.models.SharedLink as Model; - const File = mongoose.models.File as Model; - /** - * Referenced file ids (message-attachment rows carry no conversationId) are collected - * only at conversion time: post-conversion uploads always receive a deadline at upload, - * so the id scan over the chat's messages is not needed on every conforming save. - */ - const fileIds = conversationNeedsForcedRetention(parentRetention, forcedExpiredAt) - ? await collectConversationFileIds( - Message, - userId, - [conversationId], - conversationSeedFileIds(parentRetention), - ) - : []; - await forceConversationMessagesTemporary(Message, userId, conversationId, forcedExpiredAt); - await capConversationSharedLinks(SharedLink, userId, conversationId, forcedExpiredAt); - await capConversationFiles(File, userId, conversationId, forcedExpiredAt, fileIds); - } - const createdAtOnInsert = metadata?.createdAtOnInsert instanceof Date && !Number.isNaN(metadata.createdAtOnInsert.getTime()) @@ -399,6 +329,18 @@ export function createConversationMethods( return null; } + if (applyForcedRetention) { + const forcedExpiredAt = await applyForcedRetention( + conversation.conversationId ?? conversationId, + userId, + interfaceConfig, + ); + if (forcedExpiredAt instanceof Date) { + conversation.isTemporary = true; + conversation.expiredAt = forcedExpiredAt; + } + } + if ( interfaceConfig?.retentionMode === RetentionMode.ALL && typeof isTemporary !== 'boolean' && diff --git a/packages/data-schemas/src/methods/convoStructure.spec.ts b/packages/data-schemas/src/methods/convoStructure.spec.ts index 6ec2cf8c0e..f6e6e71e28 100644 --- a/packages/data-schemas/src/methods/convoStructure.spec.ts +++ b/packages/data-schemas/src/methods/convoStructure.spec.ts @@ -1,10 +1,10 @@ import mongoose from 'mongoose'; -import type { TMessage } from 'librechat-data-provider'; import { buildTree } from 'librechat-data-provider'; import { MongoMemoryServer } from 'mongodb-memory-server'; -import { createModels } from '~/models'; -import { createMessageMethods } from './message'; +import type { TMessage } from 'librechat-data-provider'; import type { IMessage } from '..'; +import { createMessageMethods } from './message'; +import { createModels } from '~/models'; jest.mock('~/config/winston', () => ({ error: jest.fn(), @@ -26,7 +26,7 @@ beforeAll(async () => { Object.assign(mongoose.models, models); Message = mongoose.models.Message; - const methods = createMessageMethods(mongoose); + const methods = createMessageMethods(mongoose, async () => null); getMessages = methods.getMessages; bulkSaveMessages = methods.bulkSaveMessages; diff --git a/packages/data-schemas/src/methods/file.spec.ts b/packages/data-schemas/src/methods/file.spec.ts index 71b12ff5aa..015c04c615 100644 --- a/packages/data-schemas/src/methods/file.spec.ts +++ b/packages/data-schemas/src/methods/file.spec.ts @@ -86,6 +86,29 @@ describe('File Methods', () => { expect(file?.file_id).toBe(fileId); expect(file?.expiresAt).toBeUndefined(); }); + + it('updates expiredAt monotonically with an atomic minimum', async () => { + const fileId = uuidv4(); + const userId = new mongoose.Types.ObjectId(); + const firstExpiry = new Date('2030-01-01T00:00:00.000Z'); + const laterExpiry = new Date('2031-01-01T00:00:00.000Z'); + const soonerExpiry = new Date('2029-01-01T00:00:00.000Z'); + const data = { + file_id: fileId, + user: userId, + filename: 'retained.txt', + filepath: '/uploads/retained.txt', + type: 'text/plain', + bytes: 100, + }; + + await fileMethods.createFile({ ...data, expiredAt: firstExpiry }, true); + const notExtended = await fileMethods.createFile({ ...data, expiredAt: laterExpiry }, true); + const shortened = await fileMethods.createFile({ ...data, expiredAt: soonerExpiry }, true); + + expect(notExtended?.expiredAt?.getTime()).toBe(firstExpiry.getTime()); + expect(shortened?.expiredAt?.getTime()).toBe(soonerExpiry.getTime()); + }); }); describe('claimCodeFile', () => { diff --git a/packages/data-schemas/src/methods/file.ts b/packages/data-schemas/src/methods/file.ts index 2e1d9159a5..b952796104 100644 --- a/packages/data-schemas/src/methods/file.ts +++ b/packages/data-schemas/src/methods/file.ts @@ -364,10 +364,27 @@ export function createFileMethods(mongoose: typeof import('mongoose')): { delete fileData.expiresAt; } - return File.findOneAndUpdate({ file_id: data.file_id }, fileData, { - new: true, - upsert: true, - }).lean(); + const { expiredAt, ...fields } = fileData; + const definedFields = Object.fromEntries( + Object.entries(fields).filter(([, value]) => value !== undefined), + ); + let expiryUpdate = {}; + if (expiredAt instanceof Date) { + expiryUpdate = { + expiredAt: { + $min: [{ $ifNull: ['$expiredAt', expiredAt] }, expiredAt], + }, + }; + } + + return File.findOneAndUpdate( + { file_id: data.file_id }, + [{ $set: { ...definedFields, ...expiryUpdate } }], + { + new: true, + upsert: true, + }, + ).lean(); } /** diff --git a/packages/data-schemas/src/methods/index.ts b/packages/data-schemas/src/methods/index.ts index e21115b8c0..5b677ea157 100644 --- a/packages/data-schemas/src/methods/index.ts +++ b/packages/data-schemas/src/methods/index.ts @@ -46,7 +46,14 @@ import { createPresetMethods, type PresetMethods } from './preset'; import { createConversationTagMethods, type ConversationTagMethods } from './conversationTag'; import { createMessageMethods, type MessageMethods } from './message'; import { createConversationMethods, type ConversationMethods } from './conversation'; -import { createChatProjectMethods, type ChatProjectMethods } from './chatProject'; +import { + createChatProjectMethods, + refreshChatProjectStatsForUser, + type ChatProjectMethods, +} from './chatProject'; +import type { ApplyForcedRetention } from '~/utils/retention'; +import { createApplyForcedRetention } from '~/utils/retention'; +import logger from '~/config/winston'; export type { AssignConversationToProjectResult, ChatProjectSortBy, @@ -145,8 +152,7 @@ export type AllMethods = UserMethods & ConversationTagMethods & MessageMethods & ConversationMethods & - ChatProjectMethods & - TxMethods & + ChatProjectMethods & { applyForcedRetention: ApplyForcedRetention } & TxMethods & TransactionMethods & SpendTokensMethods & PromptMethods & @@ -200,12 +206,21 @@ export function createMethods( createStructuredTransaction: transactionMethods.createStructuredTransaction, }); - const messageMethods = createMessageMethods(mongoose); - - const conversationMethods = createConversationMethods(mongoose, { - getMessages: messageMethods.getMessages, - deleteMessages: messageMethods.deleteMessages, + const applyForcedRetention = createApplyForcedRetention(mongoose, { + logger, + refreshProjectStats: (userId, projectId) => + refreshChatProjectStatsForUser(mongoose, userId, projectId), }); + const messageMethods = createMessageMethods(mongoose, applyForcedRetention); + + const conversationMethods = createConversationMethods( + mongoose, + { + getMessages: messageMethods.getMessages, + deleteMessages: messageMethods.deleteMessages, + }, + applyForcedRetention, + ); // ACL entry methods (used internally for removeAllPermissions) const aclEntryMethods = createAclEntryMethods(mongoose); @@ -279,7 +294,8 @@ export function createMethods( ...createConversationTagMethods(mongoose), ...messageMethods, ...conversationMethods, - ...createChatProjectMethods(mongoose), + ...createChatProjectMethods(mongoose, applyForcedRetention), + applyForcedRetention, /* Tier 3 */ ...txMethods, ...transactionMethods, diff --git a/packages/data-schemas/src/methods/message.spec.ts b/packages/data-schemas/src/methods/message.spec.ts index 24778fbd1f..d37823cd4a 100644 --- a/packages/data-schemas/src/methods/message.spec.ts +++ b/packages/data-schemas/src/methods/message.spec.ts @@ -2,9 +2,11 @@ import mongoose from 'mongoose'; import { v4 as uuidv4 } from 'uuid'; import { RetentionMode } from 'librechat-data-provider'; import { MongoMemoryServer } from 'mongodb-memory-server'; -import type { IChatProject, IConversation, IMessage, IMongoFile, ISharedLink } from '..'; -import { cascadeForcedConversationRetention, sweepForcedRetention } from '../utils/retention'; +import type { IChatProject, IConversation, IMessage, ISharedLink } from '..'; +import type { ApplyForcedRetention } from '../utils/retention'; import { tenantStorage, runAsSystem } from '~/config/tenantContext'; +import { createApplyForcedRetention } from '../utils/retention'; +import { refreshChatProjectStatsForUser } from './chatProject'; import { createMessageMethods } from './message'; import { createModels } from '../models'; import logger from '~/config/winston'; @@ -24,8 +26,7 @@ let saveMessage: ReturnType['saveMessage']; let getMessages: ReturnType['getMessages']; let updateMessage: ReturnType['updateMessage']; let updateToolCallResult: ReturnType['updateToolCallResult']; -let applyForcedRetention: ReturnType['applyForcedRetention']; -let applyForcedRetentionToTag: ReturnType['applyForcedRetentionToTag']; +let applyForcedRetention: ApplyForcedRetention; let deleteMessages: ReturnType['deleteMessages']; let bulkSaveMessages: ReturnType['bulkSaveMessages']; let updateMessageText: ReturnType['updateMessageText']; @@ -40,13 +41,16 @@ beforeAll(async () => { Object.assign(mongoose.models, models); Message = mongoose.models.Message; - const methods = createMessageMethods(mongoose); + applyForcedRetention = createApplyForcedRetention(mongoose, { + logger, + refreshProjectStats: (userId, projectId) => + refreshChatProjectStatsForUser(mongoose, userId, projectId), + }); + const methods = createMessageMethods(mongoose, applyForcedRetention); saveMessage = methods.saveMessage; getMessages = methods.getMessages; updateMessage = methods.updateMessage; updateToolCallResult = methods.updateToolCallResult; - applyForcedRetention = methods.applyForcedRetention; - applyForcedRetentionToTag = methods.applyForcedRetentionToTag; deleteMessages = methods.deleteMessages; bulkSaveMessages = methods.bulkSaveMessages; updateMessageText = methods.updateMessageText; @@ -1183,7 +1187,7 @@ describe('Message Operations', () => { interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, }, { messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' }, - { context: 'branch', capExpiryToConversation: true }, + { context: 'branch' }, ); expect(saved?.expiredAt?.getTime()).toBe(parentExpiry.getTime()); @@ -1220,7 +1224,7 @@ describe('Message Operations', () => { interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, }, { messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' }, - { context: 'branch', capExpiryToConversation: true }, + { context: 'branch' }, ); expect(saved?.expiredAt?.getTime()).toBe(parentExpiry.getTime()); @@ -1259,7 +1263,7 @@ describe('Message Operations', () => { interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, }, { messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' }, - { context: 'branch', capExpiryToConversation: true }, + { context: 'branch' }, ); const lagging = await Message.findOne({ messageId: laggingMessageId }).lean(); @@ -1333,7 +1337,7 @@ describe('Message Operations', () => { interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, }, { messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' }, - { context: 'branch', capExpiryToConversation: true }, + { context: 'branch' }, ); const reloaded = await SharedLink.findOne({ conversationId }).lean(); @@ -1362,14 +1366,10 @@ describe('Message Operations', () => { { messageId: uuidv4(), conversationId, user: 'user123', text: 'second' }, ]); - await applyForcedRetention( - { - userId: 'user123', - interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, - }, - { conversationId, messageId: editedMessageId }, - { context: 'edit', capExpiryToConversation: true }, - ); + await applyForcedRetention(conversationId, 'user123', { + temporaryChatRetention: 24, + retentionMode: RetentionMode.EPHEMERAL, + }); const convo = await Conversation().findOne({ conversationId }).lean(); expect(convo?.isTemporary).toBe(true); @@ -1405,14 +1405,10 @@ describe('Message Operations', () => { expiredAt: messageDeadline, }); - await applyForcedRetention( - { - userId: 'user123', - interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, - }, - { conversationId, messageId: editedMessageId }, - { context: 'edit', capExpiryToConversation: true }, - ); + await applyForcedRetention(conversationId, 'user123', { + temporaryChatRetention: 24, + retentionMode: RetentionMode.EPHEMERAL, + }); const message = await Message.findOne({ messageId: editedMessageId, user: 'user123' }).lean(); expect(message?.isTemporary).toBe(true); @@ -1441,14 +1437,10 @@ describe('Message Operations', () => { }); await Message.create({ messageId: uuidv4(), conversationId, user: 'user123', text: 'hi' }); - await applyForcedRetention( - { - userId: 'user123', - interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, - }, - { conversationId }, - { context: 'tag' }, - ); + await applyForcedRetention(conversationId, 'user123', { + temporaryChatRetention: 24, + retentionMode: RetentionMode.EPHEMERAL, + }); const refreshed = await ChatProject.findById(projectId).lean(); expect(refreshed?.conversationCount).toBe(0); @@ -1482,7 +1474,7 @@ describe('Message Operations', () => { interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, }, { messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' }, - { context: 'branch', capExpiryToConversation: true }, + { context: 'branch' }, ); const refreshed = await ChatProject.findById(projectId).lean(); @@ -1503,14 +1495,10 @@ describe('Message Operations', () => { { messageId: uuidv4(), conversationId, user: 'user123', text: 'second' }, ]); - await applyForcedRetention( - { - userId: 'user123', - interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, - }, - { conversationId }, - { context: 'tag' }, - ); + await applyForcedRetention(conversationId, 'user123', { + temporaryChatRetention: 24, + retentionMode: RetentionMode.EPHEMERAL, + }); const convo = await Conversation().findOne({ conversationId }).lean(); expect(convo?.isTemporary).toBe(true); @@ -1535,14 +1523,10 @@ describe('Message Operations', () => { }); await Message.create({ messageId, conversationId, user: 'user123', text: 'first' }); - await applyForcedRetention( - { - userId: 'user123', - interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.TEMPORARY }, - }, - { conversationId, messageId }, - { context: 'edit', capExpiryToConversation: true }, - ); + await applyForcedRetention(conversationId, 'user123', { + temporaryChatRetention: 24, + retentionMode: RetentionMode.TEMPORARY, + }); const convo = await Conversation().findOne({ conversationId }).lean(); expect(convo?.expiredAt ?? null).toBeNull(); @@ -1551,840 +1535,6 @@ describe('Message Operations', () => { }); }); - describe('applyForcedRetentionToTag', () => { - const Conversation = () => mongoose.models.Conversation as mongoose.Model; - const SharedLink = () => mongoose.models.SharedLink as mongoose.Model; - const File = () => mongoose.models.File as mongoose.Model; - - beforeEach(async () => { - await Conversation().deleteMany({}); - await SharedLink().deleteMany({}); - await File().deleteMany({}); - }); - - it('converts every permanent conversation carrying the tag under ephemeral mode', async () => { - const taggedA = uuidv4(); - const taggedB = uuidv4(); - const untagged = uuidv4(); - await Conversation().create([ - { conversationId: taggedA, user: 'user123', endpoint: 'openAI', tags: ['work'] }, - { conversationId: taggedB, user: 'user123', endpoint: 'openAI', tags: ['work', 'urgent'] }, - { conversationId: untagged, user: 'user123', endpoint: 'openAI', tags: ['personal'] }, - ]); - await Message.create([ - { messageId: uuidv4(), conversationId: taggedA, user: 'user123', text: 'a' }, - { messageId: uuidv4(), conversationId: taggedB, user: 'user123', text: 'b' }, - { messageId: uuidv4(), conversationId: untagged, user: 'user123', text: 'c' }, - ]); - - await applyForcedRetentionToTag( - { - userId: 'user123', - interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, - }, - { tag: 'work' }, - { context: 'DELETE /api/tags/:tag' }, - ); - - for (const conversationId of [taggedA, taggedB]) { - const convo = await Conversation().findOne({ conversationId }).lean(); - expect(convo?.isTemporary).toBe(true); - expect(convo?.expiredAt).toBeInstanceOf(Date); - const messages = await getMessages({ conversationId, user: 'user123' }); - for (const message of messages) { - expect(message.isTemporary).toBe(true); - expect(message.expiredAt).toBeInstanceOf(Date); - } - } - - const untouched = await Conversation().findOne({ conversationId: untagged }).lean(); - expect(untouched?.isTemporary ?? null).not.toBe(true); - expect(untouched?.expiredAt ?? null).toBeNull(); - }); - - it('preserves earlier expirations per tagged conversation when cascading forced retention', async () => { - const soonerConversationId = uuidv4(); - const permanentConversationId = uuidv4(); - const soonerExpiry = new Date(Date.now() + 60 * 60 * 1000); - await Conversation().create([ - { - conversationId: soonerConversationId, - user: 'user123', - endpoint: 'openAI', - tags: ['work'], - isTemporary: false, - expiredAt: soonerExpiry, - }, - { - conversationId: permanentConversationId, - user: 'user123', - endpoint: 'openAI', - tags: ['work'], - }, - ]); - await Message.create([ - { - messageId: uuidv4(), - conversationId: soonerConversationId, - user: 'user123', - text: 'sooner', - isTemporary: false, - expiredAt: soonerExpiry, - }, - { - messageId: uuidv4(), - conversationId: permanentConversationId, - user: 'user123', - text: 'permanent', - }, - ]); - await SharedLink().create([ - { - conversationId: soonerConversationId, - user: 'user123', - shareId: uuidv4(), - expiredAt: soonerExpiry, - }, - { - conversationId: permanentConversationId, - user: 'user123', - shareId: uuidv4(), - }, - ]); - - await applyForcedRetentionToTag( - { - userId: 'user123', - interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, - }, - { tag: 'work' }, - { context: 'PUT /api/tags/:tag' }, - ); - - const soonerConvo = await Conversation() - .findOne({ conversationId: soonerConversationId }) - .lean(); - expect(soonerConvo?.isTemporary).toBe(true); - expect(soonerConvo?.expiredAt?.getTime()).toBe(soonerExpiry.getTime()); - const soonerMessage = await Message.findOne({ conversationId: soonerConversationId }).lean(); - expect(soonerMessage?.isTemporary).toBe(true); - expect(soonerMessage?.expiredAt?.getTime()).toBe(soonerExpiry.getTime()); - const soonerShare = await SharedLink() - .findOne({ conversationId: soonerConversationId }) - .lean(); - expect(soonerShare?.expiredAt?.getTime()).toBe(soonerExpiry.getTime()); - - const permanentConvo = await Conversation() - .findOne({ conversationId: permanentConversationId }) - .lean(); - expect(permanentConvo?.isTemporary).toBe(true); - expect(permanentConvo?.expiredAt).toBeInstanceOf(Date); - expect(permanentConvo?.expiredAt?.getTime()).toBeGreaterThan(soonerExpiry.getTime()); - const permanentMessage = await Message.findOne({ - conversationId: permanentConversationId, - }).lean(); - expect(permanentMessage?.isTemporary).toBe(true); - expect(permanentMessage?.expiredAt?.getTime()).toBeGreaterThan(soonerExpiry.getTime()); - const permanentShare = await SharedLink() - .findOne({ conversationId: permanentConversationId }) - .lean(); - expect(permanentShare?.expiredAt?.getTime()).toBeGreaterThan(soonerExpiry.getTime()); - }); - - it('owner-scopes bulk conversation file caps when conversation ids collide', async () => { - const ownerObjectId = new mongoose.Types.ObjectId(); - const foreignOwnerObjectId = new mongoose.Types.ObjectId(); - const owner = ownerObjectId.toString(); - const foreignOwner = foreignOwnerObjectId.toString(); - const conversationId = uuidv4(); - const ownerFileId = uuidv4(); - const foreignFileId = uuidv4(); - - await Conversation().create([ - { conversationId, user: owner, endpoint: 'openAI', tags: ['work'] }, - { conversationId, user: foreignOwner, endpoint: 'openAI', tags: ['personal'] }, - ]); - await File().collection.insertMany([ - { file_id: ownerFileId, conversationId, user: ownerObjectId, expiredAt: null }, - { - file_id: foreignFileId, - conversationId, - user: foreignOwnerObjectId, - expiredAt: null, - }, - ]); - - await applyForcedRetentionToTag( - { - userId: owner, - interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, - }, - { tag: 'work' }, - { context: 'PUT /api/tags/:tag' }, - ); - - const ownerFile = await File().findOne({ file_id: ownerFileId }).lean(); - expect(ownerFile?.expiredAt).toBeInstanceOf(Date); - - const foreignFile = await File().findOne({ file_id: foreignFileId }).lean(); - expect(foreignFile?.expiredAt ?? null).toBeNull(); - }); - - it('is a no-op outside forced retention', async () => { - const conversationId = uuidv4(); - await Conversation().create({ - conversationId, - user: 'user123', - endpoint: 'openAI', - tags: ['work'], - }); - - await applyForcedRetentionToTag( - { - userId: 'user123', - interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.ALL }, - }, - { tag: 'work' }, - { context: 'PUT /api/tags/:tag' }, - ); - - const convo = await Conversation().findOne({ conversationId }).lean(); - expect(convo?.expiredAt ?? null).toBeNull(); - }); - - it('ignores a non-string tag instead of matching every conversation (NoSQL injection)', async () => { - const conversationId = uuidv4(); - await Conversation().create({ - conversationId, - user: 'user123', - endpoint: 'openAI', - tags: ['work'], - }); - - await applyForcedRetentionToTag( - { - userId: 'user123', - interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, - }, - { tag: { $gt: '' } as unknown as string }, - { context: 'PUT /api/tags/:tag' }, - ); - - const convo = await Conversation().findOne({ conversationId }).lean(); - expect(convo?.isTemporary ?? null).not.toBe(true); - expect(convo?.expiredAt ?? null).toBeNull(); - }); - }); - - describe('sweepForcedRetention', () => { - const Conversation = () => mongoose.models.Conversation as mongoose.Model; - const SharedLink = () => mongoose.models.SharedLink as mongoose.Model; - const File = () => mongoose.models.File as mongoose.Model; - - beforeEach(async () => { - await Conversation().deleteMany({}); - await SharedLink().deleteMany({}); - await File().deleteMany({}); - }); - - it('converts untouched permanent conversations, messages, and shares and skips conforming ones', async () => { - const forcedExpiredAt = new Date(Date.now() + 24 * 60 * 60 * 1000); - const permanentId = uuidv4(); - const conformingId = uuidv4(); - const soonerExpiry = new Date(Date.now() + 30 * 60 * 1000); - - await Conversation().create([ - { conversationId: permanentId, user: 'user123', endpoint: 'openAI', isTemporary: false }, - { - conversationId: conformingId, - user: 'user123', - endpoint: 'openAI', - isTemporary: true, - expiredAt: soonerExpiry, - }, - ]); - await Message.create([ - { messageId: uuidv4(), conversationId: permanentId, user: 'user123', text: 'permanent' }, - { - messageId: uuidv4(), - conversationId: conformingId, - user: 'user123', - text: 'conforming', - isTemporary: true, - expiredAt: soonerExpiry, - }, - ]); - await SharedLink().create({ - conversationId: permanentId, - user: 'user123', - shareId: uuidv4(), - }); - - const result = await sweepForcedRetention( - Conversation(), - Message, - SharedLink(), - File(), - forcedExpiredAt, - ); - expect(result).toEqual({ conversations: 1, aligned: 0, errors: 0, projects: [] }); - - const permanent = await Conversation().findOne({ conversationId: permanentId }).lean(); - expect(permanent?.isTemporary).toBe(true); - expect(permanent?.expiredAt?.getTime()).toBe(forcedExpiredAt.getTime()); - - const permanentMessages = await getMessages({ conversationId: permanentId, user: 'user123' }); - for (const message of permanentMessages) { - expect(message.isTemporary).toBe(true); - expect(message.expiredAt?.getTime()).toBe(forcedExpiredAt.getTime()); - } - - const share = await SharedLink().findOne({ conversationId: permanentId }).lean(); - expect(share?.expiredAt?.getTime()).toBe(forcedExpiredAt.getTime()); - - const conforming = await Conversation().findOne({ conversationId: conformingId }).lean(); - expect(conforming?.expiredAt?.getTime()).toBe(soonerExpiry.getTime()); - }); - - it("caps a converted conversation's permanent files while preserving sooner and unrelated ones", async () => { - const forcedExpiredAt = new Date(Date.now() + 24 * 60 * 60 * 1000); - const soonerExpiry = new Date(Date.now() + 30 * 60 * 1000); - const conversationId = uuidv4(); - const unrelatedConversationId = uuidv4(); - const permanentFileId = uuidv4(); - const soonerFileId = uuidv4(); - const unrelatedFileId = uuidv4(); - const ownerObjectId = new mongoose.Types.ObjectId(); - const owner = ownerObjectId.toString(); - - await Conversation().create({ - conversationId, - user: owner, - endpoint: 'openAI', - isTemporary: false, - }); - await File().collection.insertMany([ - { - file_id: permanentFileId, - conversationId, - user: ownerObjectId, - expiredAt: null, - }, - { - file_id: soonerFileId, - conversationId, - user: ownerObjectId, - expiredAt: soonerExpiry, - }, - { - file_id: unrelatedFileId, - conversationId: unrelatedConversationId, - user: ownerObjectId, - expiredAt: null, - }, - ]); - - await sweepForcedRetention(Conversation(), Message, SharedLink(), File(), forcedExpiredAt); - - const permanentFile = await File().findOne({ file_id: permanentFileId }).lean(); - expect(permanentFile?.expiredAt?.getTime()).toBe(forcedExpiredAt.getTime()); - - const soonerFile = await File().findOne({ file_id: soonerFileId }).lean(); - expect(soonerFile?.expiredAt?.getTime()).toBe(soonerExpiry.getTime()); - - const unrelatedFile = await File().findOne({ file_id: unrelatedFileId }).lean(); - expect(unrelatedFile?.expiredAt ?? null).toBeNull(); - }); - - it('aligns lagging children of an already-conforming temporary conversation', async () => { - const forcedExpiredAt = new Date(Date.now() + 24 * 60 * 60 * 1000); - const parentDeadline = new Date(Date.now() + 60 * 60 * 1000); - const conversationId = uuidv4(); - const fileId = uuidv4(); - const ownerObjectId = new mongoose.Types.ObjectId(); - const owner = ownerObjectId.toString(); - - await Conversation().create({ - conversationId, - user: owner, - endpoint: 'openAI', - isTemporary: true, - expiredAt: parentDeadline, - }); - await SharedLink().create({ conversationId, user: owner, shareId: uuidv4() }); - await File().collection.insertOne({ - file_id: fileId, - conversationId, - user: ownerObjectId, - expiredAt: null, - }); - - const result = await sweepForcedRetention( - Conversation(), - Message, - SharedLink(), - File(), - forcedExpiredAt, - ); - expect(result).toEqual({ conversations: 0, aligned: 1, errors: 0, projects: [] }); - - 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 parent = await Conversation().findOne({ conversationId }).lean(); - expect(parent?.expiredAt?.getTime()).toBe(parentDeadline.getTime()); - }); - - it('aligns referenced attachment files that carry no conversationId', async () => { - const forcedExpiredAt = new Date(Date.now() + 24 * 60 * 60 * 1000); - const parentDeadline = new Date(Date.now() + 60 * 60 * 1000); - const ownerObjectId = new mongoose.Types.ObjectId(); - const owner = ownerObjectId.toString(); - const conversationId = uuidv4(); - const messageFileId = uuidv4(); - - await Conversation().create({ - conversationId, - user: owner, - endpoint: 'openAI', - isTemporary: true, - expiredAt: parentDeadline, - }); - await Message.create({ - messageId: uuidv4(), - conversationId, - user: owner, - text: 'with attachment', - isTemporary: true, - expiredAt: parentDeadline, - files: [{ file_id: messageFileId, filename: 'doc.pdf' }], - }); - await File().collection.insertOne({ - file_id: messageFileId, - user: ownerObjectId, - expiredAt: null, - }); - - const result = await sweepForcedRetention( - Conversation(), - Message, - SharedLink(), - File(), - forcedExpiredAt, - ); - expect(result).toEqual({ conversations: 0, aligned: 1, errors: 0, projects: [] }); - - const file = await File().findOne({ file_id: messageFileId }).lean(); - expect(file?.expiredAt?.getTime()).toBe(parentDeadline.getTime()); - }); - - it('collects converted project memberships so callers can refresh project stats', async () => { - const forcedExpiredAt = new Date(Date.now() + 24 * 60 * 60 * 1000); - const chatProjectId = new mongoose.Types.ObjectId().toString(); - - await Conversation().create([ - { - conversationId: uuidv4(), - user: 'user123', - endpoint: 'openAI', - isTemporary: false, - chatProjectId, - }, - { - conversationId: uuidv4(), - user: 'user123', - endpoint: 'openAI', - isTemporary: false, - chatProjectId, - }, - { conversationId: uuidv4(), user: 'user123', endpoint: 'openAI', isTemporary: false }, - ]); - - const result = await sweepForcedRetention( - Conversation(), - Message, - SharedLink(), - File(), - forcedExpiredAt, - ); - - expect(result.conversations).toBe(3); - expect(result.projects).toEqual([{ user: 'user123', chatProjectId }]); - }); - - it('aligns a permanent message to a sooner parent deadline instead of the forced window', async () => { - const forcedExpiredAt = new Date(Date.now() + 24 * 60 * 60 * 1000); - const conversationId = uuidv4(); - const soonerExpiry = new Date(Date.now() + 30 * 60 * 1000); - - await Conversation().create({ - conversationId, - user: 'user123', - endpoint: 'openAI', - isTemporary: false, - expiredAt: soonerExpiry, - }); - const permanentMessageId = uuidv4(); - await Message.create({ - messageId: permanentMessageId, - conversationId, - user: 'user123', - text: 'permanent', - isTemporary: false, - }); - - await sweepForcedRetention(Conversation(), Message, SharedLink(), File(), forcedExpiredAt); - - const convo = await Conversation().findOne({ conversationId }).lean(); - expect(convo?.expiredAt?.getTime()).toBe(soonerExpiry.getTime()); - const message = await Message.findOne({ messageId: permanentMessageId }).lean(); - expect(message?.isTemporary).toBe(true); - expect(message?.expiredAt?.getTime()).toBe(soonerExpiry.getTime()); - }); - - it('assigns the forced deadline to a legacy message whose expiredAt is explicitly null', async () => { - const forcedExpiredAt = new Date(Date.now() + 24 * 60 * 60 * 1000); - const conversationId = uuidv4(); - await Conversation().create({ - conversationId, - user: 'user123', - endpoint: 'openAI', - isTemporary: false, - }); - const legacyMessageId = uuidv4(); - await Message.create({ - messageId: legacyMessageId, - conversationId, - user: 'user123', - text: 'legacy permanent', - isTemporary: false, - expiredAt: null, - }); - - await sweepForcedRetention(Conversation(), Message, SharedLink(), File(), forcedExpiredAt); - - const message = await Message.findOne({ messageId: legacyMessageId }).lean(); - expect(message?.isTemporary).toBe(true); - expect(message?.expiredAt?.getTime()).toBe(forcedExpiredAt.getTime()); - }); - - it('leaves a conversation non-conforming when a child backfill fails so a re-run retries it', async () => { - const forcedExpiredAt = new Date(Date.now() + 24 * 60 * 60 * 1000); - const conversationId = uuidv4(); - await Conversation().create({ - conversationId, - user: 'user123', - endpoint: 'openAI', - isTemporary: false, - }); - - const throwingSharedLink = { - updateMany: jest.fn().mockRejectedValue(new Error('backfill failed')), - } as unknown as mongoose.Model; - - const failed = await sweepForcedRetention( - Conversation(), - Message, - throwingSharedLink, - File(), - forcedExpiredAt, - ); - expect(failed).toEqual({ conversations: 0, aligned: 0, errors: 1, projects: [] }); - - const stillPermanent = await Conversation().findOne({ conversationId }).lean(); - expect(stillPermanent?.isTemporary ?? null).not.toBe(true); - expect(stillPermanent?.expiredAt ?? null).toBeNull(); - - const retried = await sweepForcedRetention( - Conversation(), - Message, - SharedLink(), - File(), - forcedExpiredAt, - ); - expect(retried).toEqual({ conversations: 1, aligned: 0, errors: 0, projects: [] }); - - const converted = await Conversation().findOne({ conversationId }).lean(); - expect(converted?.isTemporary).toBe(true); - expect(converted?.expiredAt?.getTime()).toBe(forcedExpiredAt.getTime()); - }); - - it('does not extend a parent shortened after the sweep cursor read', async () => { - const forcedExpiredAt = new Date(Date.now() + 24 * 60 * 60 * 1000); - const soonerExpiry = new Date(Date.now() + 30 * 60 * 1000); - const owner = new mongoose.Types.ObjectId().toString(); - const conversationId = uuidv4(); - const convo = await Conversation().create({ - conversationId, - user: owner, - endpoint: 'openAI', - isTemporary: false, - }); - const FileModel = File(); - const updateMany = FileModel.updateMany.bind(FileModel); - const racingUpdateMany = jest.fn( - async ( - filter: mongoose.FilterQuery, - update: mongoose.UpdateQuery, - ) => { - await Conversation().collection.updateOne( - { _id: convo._id }, - { $set: { isTemporary: true, expiredAt: soonerExpiry } }, - ); - return updateMany(filter, update); - }, - ); - Object.assign(FileModel, { updateMany: racingUpdateMany }); - const result = await (async () => { - try { - return await sweepForcedRetention( - Conversation(), - Message, - SharedLink(), - FileModel, - forcedExpiredAt, - ); - } finally { - Object.assign(FileModel, { updateMany }); - } - })(); - - expect(racingUpdateMany).toHaveBeenCalledTimes(1); - expect(result).toEqual({ conversations: 0, aligned: 0, errors: 0, projects: [] }); - const parent = await Conversation().findById(convo._id).lean(); - expect(parent?.isTemporary).toBe(true); - expect(parent?.expiredAt?.getTime()).toBe(soonerExpiry.getTime()); - }); - - it('scopes the sweep to the active tenant context, leaving other tenants untouched', async () => { - const forcedExpiredAt = new Date(Date.now() + 24 * 60 * 60 * 1000); - const tenantAConversationId = uuidv4(); - const tenantBConversationId = uuidv4(); - await Conversation().collection.insertMany([ - { - conversationId: tenantAConversationId, - user: 'user123', - endpoint: 'openAI', - isTemporary: false, - tenantId: 'tenant-a', - }, - { - conversationId: tenantBConversationId, - user: 'user123', - endpoint: 'openAI', - isTemporary: false, - tenantId: 'tenant-b', - }, - ]); - - const result = await tenantStorage.run({ tenantId: 'tenant-a' }, async () => - sweepForcedRetention(Conversation(), Message, SharedLink(), File(), forcedExpiredAt), - ); - expect(result).toEqual({ conversations: 1, aligned: 0, errors: 0, projects: [] }); - - const tenantA = await Conversation().collection.findOne({ - conversationId: tenantAConversationId, - }); - expect(tenantA?.isTemporary).toBe(true); - - const tenantB = await Conversation().collection.findOne({ - conversationId: tenantBConversationId, - }); - expect(tenantB?.isTemporary ?? null).not.toBe(true); - expect(tenantB?.expiredAt ?? null).toBeNull(); - }); - }); - - describe('cascadeForcedConversationRetention', () => { - const Conversation = () => mongoose.models.Conversation as mongoose.Model; - const SharedLink = () => mongoose.models.SharedLink as mongoose.Model; - const File = () => mongoose.models.File as mongoose.Model; - - beforeEach(async () => { - await Conversation().deleteMany({}); - await SharedLink().deleteMany({}); - await File().deleteMany({}); - }); - - it('caps referenced attachment files that carry no conversationId when converting', async () => { - const forcedExpiredAt = new Date(Date.now() + 24 * 60 * 60 * 1000); - const ownerObjectId = new mongoose.Types.ObjectId(); - const owner = ownerObjectId.toString(); - const conversationId = uuidv4(); - const messageFileId = uuidv4(); - const toolOutputFileId = uuidv4(); - const convoFileId = uuidv4(); - const threadFileId = uuidv4(); - const foreignFileId = uuidv4(); - const conversationFileId = uuidv4(); - const foreignConversationFileId = uuidv4(); - const foreignOwnerObjectId = new mongoose.Types.ObjectId(); - - await Conversation().create({ - conversationId, - user: owner, - endpoint: 'openAI', - isTemporary: false, - files: [convoFileId], - file_ids: [threadFileId], - }); - await Message.create({ - messageId: uuidv4(), - conversationId, - user: owner, - text: 'with attachment', - files: [ - { file_id: messageFileId, filename: 'doc.pdf' }, - { file_id: foreignFileId, filename: 'crafted.pdf' }, - ], - attachments: [{ file_id: toolOutputFileId, filename: 'chart.png' }], - }); - await File().collection.insertMany([ - { file_id: messageFileId, user: ownerObjectId, expiredAt: null }, - { file_id: toolOutputFileId, user: ownerObjectId, expiredAt: null }, - { file_id: convoFileId, user: ownerObjectId, expiredAt: null }, - { file_id: threadFileId, user: ownerObjectId, expiredAt: null }, - { file_id: foreignFileId, user: new mongoose.Types.ObjectId(), expiredAt: null }, - { file_id: conversationFileId, conversationId, user: ownerObjectId, expiredAt: null }, - { - file_id: foreignConversationFileId, - conversationId, - user: foreignOwnerObjectId, - expiredAt: null, - }, - ]); - - await cascadeForcedConversationRetention( - Conversation(), - Message, - SharedLink(), - File(), - owner, - conversationId, - forcedExpiredAt, - ); - - const messageFile = await File().findOne({ file_id: messageFileId }).lean(); - expect(messageFile?.expiredAt?.getTime()).toBe(forcedExpiredAt.getTime()); - - const toolOutputFile = await File().findOne({ file_id: toolOutputFileId }).lean(); - expect(toolOutputFile?.expiredAt?.getTime()).toBe(forcedExpiredAt.getTime()); - - const convoFile = await File().findOne({ file_id: convoFileId }).lean(); - expect(convoFile?.expiredAt?.getTime()).toBe(forcedExpiredAt.getTime()); - - const threadFile = await File().findOne({ file_id: threadFileId }).lean(); - expect(threadFile?.expiredAt?.getTime()).toBe(forcedExpiredAt.getTime()); - - const conversationFile = await File().findOne({ file_id: conversationFileId }).lean(); - expect(conversationFile?.expiredAt?.getTime()).toBe(forcedExpiredAt.getTime()); - - /** - * A crafted reference to another user's file must never shorten that file's retention: - * the referenced-id cap is owner-scoped, so the foreign row stays permanent. - */ - const foreignFile = await File().findOne({ file_id: foreignFileId }).lean(); - expect(foreignFile?.expiredAt ?? null).toBeNull(); - - const foreignConversationFile = await File() - .findOne({ file_id: foreignConversationFileId }) - .lean(); - expect(foreignConversationFile?.expiredAt ?? null).toBeNull(); - }); - - it('caps lagging children even when the parent conversation already conforms', async () => { - const forcedExpiredAt = new Date(Date.now() + 24 * 60 * 60 * 1000); - const parentDeadline = new Date(Date.now() + 60 * 60 * 1000); - const conversationId = uuidv4(); - const fileId = uuidv4(); - const ownerObjectId = new mongoose.Types.ObjectId(); - const owner = ownerObjectId.toString(); - - await Conversation().create({ - conversationId, - user: owner, - endpoint: 'openAI', - isTemporary: true, - expiredAt: parentDeadline, - }); - await SharedLink().create({ conversationId, user: owner, shareId: uuidv4() }); - await File().collection.insertOne({ - file_id: fileId, - conversationId, - user: ownerObjectId, - expiredAt: null, - }); - - await cascadeForcedConversationRetention( - Conversation(), - Message, - SharedLink(), - File(), - owner, - conversationId, - forcedExpiredAt, - ); - - 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 parent = await Conversation().findOne({ conversationId }).lean(); - expect(parent?.expiredAt?.getTime()).toBe(parentDeadline.getTime()); - }); - - it('leaves the conversation non-conforming when a child backfill fails so a re-run retries it', async () => { - const forcedExpiredAt = new Date(Date.now() + 24 * 60 * 60 * 1000); - const conversationId = uuidv4(); - const owner = new mongoose.Types.ObjectId().toString(); - await Conversation().create({ - conversationId, - user: owner, - endpoint: 'openAI', - isTemporary: false, - }); - - const throwingFile = { - updateMany: jest.fn().mockRejectedValue(new Error('file backfill failed')), - } as unknown as mongoose.Model; - - await expect( - cascadeForcedConversationRetention( - Conversation(), - Message, - SharedLink(), - throwingFile, - owner, - conversationId, - forcedExpiredAt, - ), - ).rejects.toThrow('file backfill failed'); - - const stillPermanent = await Conversation().findOne({ conversationId }).lean(); - expect(stillPermanent?.isTemporary ?? null).not.toBe(true); - expect(stillPermanent?.expiredAt ?? null).toBeNull(); - - await cascadeForcedConversationRetention( - Conversation(), - Message, - SharedLink(), - File(), - owner, - conversationId, - forcedExpiredAt, - ); - - const converted = await Conversation().findOne({ conversationId }).lean(); - expect(converted?.isTemporary).toBe(true); - expect(converted?.expiredAt?.getTime()).toBe(forcedExpiredAt.getTime()); - }); - }); - describe('Message cursor pagination', () => { /** * Helper to create messages with specific timestamps diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts index 331702fc83..716f7c1694 100644 --- a/packages/data-schemas/src/methods/message.ts +++ b/packages/data-schemas/src/methods/message.ts @@ -1,15 +1,9 @@ -import { RetentionMode, isForcedTemporaryRetention } from 'librechat-data-provider'; +import { RetentionMode } from 'librechat-data-provider'; import type { DeleteResult, FilterQuery, Model } from 'mongoose'; -import type { AppConfig, IConversation, IMessage, IMongoFile, ISharedLink } from '~/types'; -import { - capForcedRetentionExpiry, - capForcedRetentionToParent, - cascadeForcedConversationRetention, - cascadeForcedRetentionByTag, - createFallbackRetentionDate, -} from '~/utils/retention'; +import type { ApplyForcedRetention } from '~/utils/retention'; +import type { AppConfig, IMessage } from '~/types'; import { createTempChatExpirationDate } from '~/utils/tempChatRetention'; -import { refreshChatProjectStatsForUser } from './chatProject'; +import { createFallbackRetentionDate } from '~/utils/retention'; import { tenantSafeBulkWrite } from '~/utils/tenantBulkWrite'; import logger from '~/config/winston'; @@ -25,7 +19,7 @@ export interface MessageMethods { saveMessage( ctx: { userId: string; isTemporary?: boolean; interfaceConfig?: AppConfig['interfaceConfig'] }, params: Partial & { newMessageId?: string }, - metadata?: { context?: string; capExpiryToConversation?: boolean }, + metadata?: { context?: string }, ): Promise; bulkSaveMessages( messages: Array>, @@ -54,16 +48,6 @@ export interface MessageMethods { message: Partial & { newMessageId?: string }, metadata?: { context?: string }, ): Promise>; - applyForcedRetention( - ctx: { userId: string; interfaceConfig?: AppConfig['interfaceConfig'] }, - params: { conversationId: string; messageId?: string }, - metadata?: { context?: string; capExpiryToConversation?: boolean }, - ): Promise; - applyForcedRetentionToTag( - ctx: { userId: string; interfaceConfig?: AppConfig['interfaceConfig'] }, - params: { tag: string }, - metadata?: { context?: string }, - ): Promise; deleteMessagesSince( userId: string, params: { messageId: string; conversationId: string }, @@ -91,7 +75,10 @@ export interface MessageMethods { deleteMessages(filter: FilterQuery): Promise; } -export function createMessageMethods(mongoose: typeof import('mongoose')): MessageMethods { +export function createMessageMethods( + mongoose: typeof import('mongoose'), + applyForcedRetention: ApplyForcedRetention, +): MessageMethods { /** * Saves a message in the database. */ @@ -106,7 +93,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa interfaceConfig?: AppConfig['interfaceConfig']; }, params: Partial & { newMessageId?: string }, - metadata?: { context?: string; capExpiryToConversation?: boolean }, + metadata?: { context?: string }, ) { if (!userId) { throw new Error('User not authenticated'); @@ -129,14 +116,8 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa }; if (interfaceConfig?.retentionMode === RetentionMode.EPHEMERAL) { - update.isTemporary = true; - try { - update.expiredAt = createTempChatExpirationDate(interfaceConfig); - } catch (err) { - logger.error('Error creating temporary chat expiration date:', err); - logger.info(`---\`saveMessage\` context: ${metadata?.context}`); - update.expiredAt = createFallbackRetentionDate(); - } + delete update.isTemporary; + delete update.expiredAt; } else if (interfaceConfig?.retentionMode === RetentionMode.ALL) { if (typeof isTemporary === 'boolean') { update.isTemporary = isTemporary; @@ -170,36 +151,6 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa update.tokenCount = 0; } - const forcedExpiredAt = update.expiredAt; - const isForcedRetention = isForcedTemporaryRetention(interfaceConfig?.retentionMode); - /** - * Under forced retention the new message must never outlive a parent that already - * expires sooner, since `saveConvo` preserves that earlier deadline rather than - * refreshing it. Message-only saves (branch/artifact/abort) additionally backfill the - * parent's other messages and shares because no `saveConvo` follows to cascade; normal - * saves only cap the message itself and let the conversation cascade handle the rest. - */ - if (isForcedRetention && forcedExpiredAt instanceof Date) { - const Conversation = mongoose.models.Conversation as Model; - if (metadata?.capExpiryToConversation === true) { - const SharedLink = mongoose.models.SharedLink as Model; - update.expiredAt = await capForcedRetentionToParent( - Conversation, - Message, - SharedLink, - userId, - conversationId, - forcedExpiredAt, - ); - } else { - const parent = await Conversation.findOne( - { conversationId, user: userId }, - 'expiredAt', - ).lean<{ expiredAt?: Date | null } | null>(); - update.expiredAt = capForcedRetentionExpiry(parent?.expiredAt, forcedExpiredAt); - } - } - const message = await Message.findOneAndUpdate( { messageId: params.messageId, user: userId }, update, @@ -219,29 +170,14 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa message.isTemporary = false; } - const cascadeExpiredAt = update.expiredAt; - if (isForcedRetention && cascadeExpiredAt instanceof Date) { - const Conversation = mongoose.models.Conversation as Model; - const SharedLink = mongoose.models.SharedLink as Model; - const File = mongoose.models.File as Model; - const convertedParent = await cascadeForcedConversationRetention( - Conversation, - Message, - SharedLink, - File, - userId, - conversationId, - cascadeExpiredAt, - ); - /** - * Message-only writes (branch/artifact/abort saves) have no `saveConvo` afterward to - * recompute project stats, so when the cascade just hid a project chat the cached - * count/lastConversationId must be refreshed here. - */ - if (convertedParent) { - await refreshForcedRetentionProjectStats(userId, { - conversationId, - } as FilterQuery); + const forcedExpiredAt = await applyForcedRetention(conversationId, userId, interfaceConfig); + if (forcedExpiredAt instanceof Date) { + message.isTemporary = true; + if ( + !(message.expiredAt instanceof Date) || + message.expiredAt.getTime() > forcedExpiredAt.getTime() + ) { + message.expiredAt = forcedExpiredAt; } } @@ -549,159 +485,6 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa } } - /** - * Recomputes the owning project's cached stats after forced retention hides project chats. - * A conversion flips the conversation to `isTemporary: true`, which - * `visibleProjectConversationFilter` excludes, so a stale count/`lastConversationId` would keep - * pointing at a chat the project workspace no longer shows — matching `saveConvo`, which already - * refreshes project stats on a retention-visibility change. Scoped to conversations carrying a - * `chatProjectId`; a no-op when none of the touched chats belong to a project. - */ - async function refreshForcedRetentionProjectStats( - userId: string, - conversationFilter: FilterQuery, - ): Promise { - const Conversation = mongoose.models.Conversation as Model; - const projectChats = await Conversation.find( - { user: userId, ...conversationFilter, chatProjectId: { $exists: true, $ne: null } }, - 'chatProjectId', - ).lean>(); - if (projectChats.length === 0) { - return; - } - - const projectIds = new Set(); - for (const chat of projectChats) { - if (typeof chat.chatProjectId === 'string' && chat.chatProjectId.length > 0) { - projectIds.add(chat.chatProjectId); - } - } - for (const projectId of projectIds) { - await refreshChatProjectStatsForUser(mongoose, userId, projectId); - } - } - - /** - * Enforces forced (ephemeral) retention on a conversation (and optionally a specific - * message) that was touched outside `saveMessage`/`saveConvo` — message edits, feedback, - * or bookmark-tag writes. Without these, an older permanent chat touched after an install - * switches to ephemeral would stay visible and never expire. Omit `messageId` for - * conversation-only writes (e.g. tag changes) to run just the conversation cascade. - */ - async function applyForcedRetention( - { userId, interfaceConfig }: { userId: string; interfaceConfig?: AppConfig['interfaceConfig'] }, - { conversationId, messageId }: { conversationId: string; messageId?: string }, - metadata?: { context?: string; capExpiryToConversation?: boolean }, - ): Promise { - if (!isForcedTemporaryRetention(interfaceConfig?.retentionMode)) { - return; - } - if (typeof conversationId !== 'string' || conversationId.length === 0) { - logger.warn( - `[applyForcedRetention] Ignoring non-string conversationId (context: ${metadata?.context ?? 'n/a'})`, - ); - return; - } - - let forcedExpiredAt: Date; - try { - forcedExpiredAt = createTempChatExpirationDate(interfaceConfig); - } catch (err) { - logger.error('Error creating temporary chat expiration date:', err); - logger.info(`---\`applyForcedRetention\` context: ${metadata?.context}`); - forcedExpiredAt = createFallbackRetentionDate(); - } - - const Message = mongoose.models.Message as Model; - const Conversation = mongoose.models.Conversation as Model; - const SharedLink = mongoose.models.SharedLink as Model; - const File = mongoose.models.File as Model; - - if (metadata?.capExpiryToConversation === true) { - forcedExpiredAt = await capForcedRetentionToParent( - Conversation, - Message, - SharedLink, - userId, - conversationId, - forcedExpiredAt, - ); - } - - if (typeof messageId === 'string' && messageId.length > 0) { - await Message.updateOne({ messageId, user: userId }, [ - { - $set: { - isTemporary: true, - expiredAt: { $min: [{ $ifNull: ['$expiredAt', forcedExpiredAt] }, forcedExpiredAt] }, - }, - }, - ]); - } - const convertedParent = await cascadeForcedConversationRetention( - Conversation, - Message, - SharedLink, - File, - userId, - conversationId, - forcedExpiredAt, - ); - if (convertedParent) { - await refreshForcedRetentionProjectStats(userId, { - conversationId, - } as FilterQuery); - } - } - - /** - * Enforces forced (ephemeral) retention on every conversation carrying a bookmark tag, - * for tag-scoped writes that bypass `saveConvo`/`applyForcedRetention` — global tag renames - * and deletes that `Conversation.updateMany` the tag on/off existing chats. Without this an - * older permanent chat touched only by a tag change after an install switches to ephemeral - * would stay visible and never expire. A no-op outside forced retention. - */ - async function applyForcedRetentionToTag( - { userId, interfaceConfig }: { userId: string; interfaceConfig?: AppConfig['interfaceConfig'] }, - { tag }: { tag: string }, - metadata?: { context?: string }, - ): Promise { - if (!isForcedTemporaryRetention(interfaceConfig?.retentionMode)) { - return; - } - if (typeof tag !== 'string' || tag.length === 0) { - logger.warn( - `[applyForcedRetentionToTag] Ignoring non-string tag (context: ${metadata?.context ?? 'n/a'})`, - ); - return; - } - - let forcedExpiredAt: Date; - try { - forcedExpiredAt = createTempChatExpirationDate(interfaceConfig); - } catch (err) { - logger.error('Error creating temporary chat expiration date:', err); - logger.info(`---\`applyForcedRetentionToTag\` context: ${metadata?.context}`); - forcedExpiredAt = createFallbackRetentionDate(); - } - - const Message = mongoose.models.Message as Model; - const Conversation = mongoose.models.Conversation as Model; - const SharedLink = mongoose.models.SharedLink as Model; - const File = mongoose.models.File as Model; - - await cascadeForcedRetentionByTag( - Conversation, - Message, - SharedLink, - File, - userId, - tag, - forcedExpiredAt, - ); - await refreshForcedRetentionProjectStats(userId, { tags: tag } as FilterQuery); - } - /** * Deletes messages in a conversation since a specific message. */ @@ -839,8 +622,6 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa updateMessageText, updateToolCallResult, updateMessage, - applyForcedRetention, - applyForcedRetentionToTag, deleteMessagesSince, getMessages, getMessage, diff --git a/packages/data-schemas/src/utils/retention.ts b/packages/data-schemas/src/utils/retention.ts index a3577aca2c..0b00ebb028 100644 --- a/packages/data-schemas/src/utils/retention.ts +++ b/packages/data-schemas/src/utils/retention.ts @@ -1,14 +1,38 @@ -import type { FilterQuery, Model } from 'mongoose'; -import type { AppConfig, IConversation, IMessage, IMongoFile, ISharedLink } from '~/types'; +import { ResourceType, RetentionMode } from 'librechat-data-provider'; +import type { FilterQuery, Model, Types } from 'mongoose'; +import type { + AppConfig, + IAclEntry, + IConversation, + IMessage, + IMongoFile, + ISharedLink, +} from '~/types'; +import type { IConversationTag } from '~/schema/conversationTag'; import { createTempChatExpirationDate, DEFAULT_RETENTION_HOURS } from './tempChatRetention'; +import { tenantSafeBulkWrite } from './tenantBulkWrite'; import { isValidObjectIdString } from './objectId'; -import logger from '~/config/winston'; export type RetentionFilterDocument = { isTemporary?: boolean | null; expiredAt?: Date | null; }; +type RetentionLogger = { + error: (message: string, error?: unknown) => void; +}; + +type ApplyForcedRetentionDependencies = { + logger: RetentionLogger; + refreshProjectStats: (userId: string, projectId: string) => Promise; +}; + +export type ApplyForcedRetention = ( + conversationId: string, + userId: string, + appConfig?: AppConfig['interfaceConfig'], +) => Promise; + export const activeExpirationFilter = < T extends RetentionFilterDocument = RetentionFilterDocument, >(): FilterQuery => @@ -34,616 +58,205 @@ export const buildRetentionVisibilityFilter = < export const createFallbackRetentionDate = (now: number = Date.now()): Date => new Date(now + DEFAULT_RETENTION_HOURS * 60 * 60 * 1000); -/** - * Resolves the forced-retention deadline from the interface config, falling back to the default - * window when the configured retention hours cannot be computed. - */ -export const resolveForcedRetentionDate = ( - interfaceConfig?: AppConfig['interfaceConfig'], +const expirationPipeline = (expiredAt: Date, includeTemporary = false) => [ + { + $set: { + ...(includeTemporary ? { isTemporary: true } : {}), + expiredAt: { $min: [{ $ifNull: ['$expiredAt', expiredAt] }, expiredAt] }, + }, + }, +]; + +const resolveForcedRetentionDate = ( + appConfig: AppConfig['interfaceConfig'] | undefined, + logger: RetentionLogger, ): Date => { try { - return createTempChatExpirationDate(interfaceConfig); - } catch (err) { - logger.error('Error creating forced retention expiration date:', err); + return createTempChatExpirationDate(appConfig); + } catch (error) { + logger.error('Error creating forced-retention expiration date:', error); return createFallbackRetentionDate(); } }; -/** - * Matches retention documents that do not yet conform to a forced (ephemeral) deadline: - * not temporary, missing an expiration, or expiring later than the forced window. The - * last clause re-caps documents carried over from a longer policy (`all`, or a longer - * `temporary` TTL) while leaving already-conforming temporary documents untouched. - */ -export const forcedRetentionGapFilter = < - T extends RetentionFilterDocument = RetentionFilterDocument, ->( - forcedExpiredAt: Date, -): FilterQuery => - ({ - $or: [ - { isTemporary: { $ne: true } }, - { expiredAt: null }, - { expiredAt: { $gt: forcedExpiredAt } }, - ], - }) as FilterQuery; - -/** - * In-memory counterpart of {@link forcedRetentionGapFilter} for a conversation's prior - * state: true when the parent must be re-capped to the forced deadline. - */ -export const conversationNeedsForcedRetention = ( - parent: RetentionFilterDocument | null | undefined, - forcedExpiredAt: Date, -): boolean => { - if (parent == null) { - return false; - } - if (parent.isTemporary !== true || parent.expiredAt == null) { - return true; - } - return parent.expiredAt.getTime() > forcedExpiredAt.getTime(); -}; - -export const capForcedRetentionExpiry = ( - expiredAt: Date | null | undefined, - forcedExpiredAt: Date, -): Date => { - if (!(expiredAt instanceof Date)) { - return forcedExpiredAt; - } - - const existingTime = expiredAt.getTime(); - if (!Number.isNaN(existingTime) && existingTime < forcedExpiredAt.getTime()) { - return expiredAt; - } - - return forcedExpiredAt; -}; - -/** - * Applies forced-retention deadlines to a conversation's messages that do not yet - * conform to the forced window. - * - * Forced (ephemeral) retention must cover existing messages too. A conversation that - * predates the mode keeps non-conforming messages — `expiredAt: null` permanent messages, - * `isTemporary: false` messages carried over from `all` retention, or temporary messages - * whose `expiredAt` is later than a newly shortened window — that would otherwise outlive - * the converted conversation. The gap filter pulls all of them onto the ephemeral schedule - * and stays a no-op once a conversation already conforms. - * - * Each message keeps its own earlier deadline: a carried-over message whose per-message TTL - * already expires sooner than the forced window is marked temporary but keeps its `expiredAt`, - * so converting the conversation never extends data that was already scheduled to expire - * sooner. A permanent message (`expiredAt` null/missing) instead receives the forced deadline - * (`$ifNull` guards `$min` from selecting the null), so the TTL index can remove it. - */ -export const forceConversationMessagesTemporary = async ( +const collectReferencedFileIds = async ( Message: Model, userId: string, conversationId: string, - expiredAt: Date, -): Promise => { - const result = await Message.updateMany( - { conversationId, user: userId, ...forcedRetentionGapFilter(expiredAt) }, - [ - { - $set: { - isTemporary: true, - expiredAt: { $min: [{ $ifNull: ['$expiredAt', expiredAt] }, expiredAt] }, - }, - }, - ], - ); - return result.modifiedCount ?? 0; -}; - -/** - * Caps a conversation's shared links to the forced deadline. A share embeds a snapshot of - * the conversation (message refs and file snapshots) and its TTL index keys off `expiredAt` - * alone, so a permanent share (`expiredAt: null`) created before forced retention would stay - * publicly readable after the conversation and messages expire. Only links with no - * expiration or a later one are touched, so it is a no-op once a conversation conforms. - */ -export const capConversationSharedLinks = async ( - SharedLink: Model, - userId: string, - conversationId: string, - forcedExpiredAt: Date, -): Promise => { - const result = await SharedLink.updateMany( - { - conversationId, - user: userId, - $or: [{ expiredAt: null }, { expiredAt: { $gt: forcedExpiredAt } }], - }, - { $set: { expiredAt: forcedExpiredAt } }, - ); - return result.modifiedCount ?? 0; -}; - -/** - * Collects the file ids a set of conversations references. Message-attachment uploads create - * File rows without a `conversationId` — they are referenced only from `Message.files[].file_id`, - * `Message.attachments[].file_id` (tool/agent outputs), and the conversation's own `files` - * array — so conversation-scoped file caps must also target these ids. `seedFileIds` takes the - * conversations' `files` arrays; message references are read in one pass over the conversations' - * messages. - */ -export const collectConversationFileIds = async ( - Message: Model, - userId: string, - conversationIds: string[], - seedFileIds?: Iterable, + parent: Pick, ): Promise => { - const fileIds = new Set(); - const addFileIds = (references?: Array<{ file_id?: unknown } | null>) => { + const fileIds = new Set([...(parent.files ?? []), ...(parent.file_ids ?? [])]); + const messages = await Message.find( + { + user: userId, + conversationId, + $or: [{ files: { $exists: true, $ne: null } }, { attachments: { $exists: true, $ne: null } }], + }, + 'files attachments', + ).lean< + Array<{ + files?: Array<{ file_id?: unknown } | null>; + attachments?: Array<{ file_id?: unknown } | null>; + }> + >(); + + const addReferences = (references?: Array<{ file_id?: unknown } | null>) => { for (const reference of references ?? []) { - const fileId = reference?.file_id; - if (typeof fileId === 'string' && fileId.length > 0) { - fileIds.add(fileId); + if (typeof reference?.file_id === 'string' && reference.file_id.length > 0) { + fileIds.add(reference.file_id); } } }; - - for (const fileId of seedFileIds ?? []) { - if (typeof fileId === 'string' && fileId.length > 0) { - fileIds.add(fileId); - } + for (const message of messages) { + addReferences(message.files); + addReferences(message.attachments); } - if (conversationIds.length > 0) { - const messages = await Message.find( - { - user: userId, - conversationId: { $in: conversationIds }, - $or: [ - { files: { $exists: true, $ne: null } }, - { attachments: { $exists: true, $ne: null } }, - ], - }, - 'files attachments', - ).lean< - Array<{ - files?: Array<{ file_id?: unknown } | null>; - attachments?: Array<{ file_id?: unknown } | null>; - }> - >(); - for (const message of messages) { - addFileIds(message.files); - addFileIds(message.attachments); - } - } - return [...fileIds]; + return [...fileIds].filter(Boolean); }; -/** - * Concatenates a conversation's own file references: `files` (regular uploads) and `file_ids` - * (Assistants thread uploads persisted by saveUserMessage/syncMessages). Used to seed - * {@link collectConversationFileIds} so both reference styles are capped. - */ -export const conversationSeedFileIds = (convo: { - files?: string[] | null; - file_ids?: string[] | null; -}): string[] => [...(convo.files ?? []), ...(convo.file_ids ?? [])]; - -/** Builds an owner-scoped file-cap filter for conversation and referenced-file matches. */ -const ownedFileScope = ( +const createOwnedFileScope = ( userId: string, - conversationId: string | { $in: string[] }, + conversationId: string, fileIds: string[], ): FilterQuery | null => { if (!isValidObjectIdString(userId)) { return null; } - const conversationScope = { conversationId, user: userId }; - if (fileIds.length === 0) { - return conversationScope; - } return { - $or: [conversationScope, { file_id: { $in: fileIds }, user: userId }], - }; + $or: [ + { user: userId, conversationId }, + ...(fileIds.length > 0 ? [{ user: userId, file_id: { $in: fileIds } }] : []), + ], + } as FilterQuery; }; -/** - * Caps a conversation's uploaded files to the forced deadline. Files use a retention-scoped - * `expiredAt` swept by application code (`getExpiredFiles` only sweeps files whose own `expiredAt` - * is set), so a permanent file (`expiredAt: null`) uploaded before forced retention would linger - * in storage after the conversation and messages TTL out. Only files with no expiration or a later - * one are touched, so it is a no-op once a conversation conforms and never extends a file that - * already expires sooner. Under ephemeral retention every conversation-scoped file is meant to - * expire (persistent agent files are not retained), so no agent-file exclusion is needed here. - * - * Matches by `conversationId` plus any referenced `fileIds`. Both branches are scoped by - * `File.user` because conversation ids are unique only together with their owner and tenant, - * while referenced ids can be caller-supplied. - * A shared file referenced from several chats is capped to the earliest converting chat's - * deadline, consistent with cap-don't-extend. - */ -export const capConversationFiles = async ( - File: Model, - userId: string, - conversationId: string, - forcedExpiredAt: Date, - fileIds: string[] = [], -): Promise => { - const scope = ownedFileScope(userId, conversationId, fileIds); - if (scope == null) { - return 0; - } - const result = await File.updateMany( - { - $and: [scope, { $or: [{ expiredAt: null }, { expiredAt: { $gt: forcedExpiredAt } }] }], - } as FilterQuery, - { $set: { expiredAt: forcedExpiredAt } }, - ); - return result.modifiedCount ?? 0; -}; - -/** - * Caps a message-only forced save to a parent that already expires sooner than the freshly - * computed window. Returns the parent's earlier deadline (so the message cannot outlive it) - * and backfills the conversation's lagging messages to that deadline — the cascade leaves - * 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 ( +const reconcileTagCounts = async ( Conversation: Model, - Message: Model, - SharedLink: Model, userId: string, - conversationId: string, - forcedExpiredAt: Date, -): Promise => { - const parent = await Conversation.findOne({ conversationId, user: userId }, 'expiredAt').lean<{ - expiredAt?: Date | null; - } | null>(); - const expiredAt = capForcedRetentionExpiry(parent?.expiredAt, forcedExpiredAt); - if (expiredAt !== forcedExpiredAt) { - await forceConversationMessagesTemporary(Message, userId, conversationId, expiredAt); - await capConversationSharedLinks(SharedLink, userId, conversationId, expiredAt); - } - return expiredAt; -}; - -/** - * Converts or re-caps a parent conversation to the forced deadline and, when that first - * brings the conversation into the forced window, backfills its lagging messages, shares, and - * files. Shared by every forced-retention message-write path so a single conversation/message - * rule is enforced regardless of which save touched the chat. Returns whether the parent row - * was converted, so callers can refresh caches (e.g. project stats) that a visibility flip - * invalidates. - */ -export const cascadeForcedConversationRetention = async ( - Conversation: Model, - Message: Model, - SharedLink: Model, - File: Model, - userId: string, - conversationId: string, - forcedExpiredAt: Date, -): Promise => { - const parent = await Conversation.findOne( - { conversationId, user: userId }, - 'isTemporary expiredAt files file_ids', - ).lean<(RetentionFilterDocument & { files?: string[]; file_ids?: string[] }) | null>(); - if (parent == null) { - return false; - } - const expiredAt = capForcedRetentionExpiry(parent.expiredAt, forcedExpiredAt); - const needsConversion = conversationNeedsForcedRetention(parent, expiredAt); - /** - * Referenced file ids (message-attachment rows carry no conversationId) are collected only at - * conversion time: post-conversion uploads always receive a deadline at upload, so the id - * scan over the chat's messages is not needed on every conforming-parent write. - */ - const fileIds = needsConversion - ? await collectConversationFileIds( - Message, - userId, - [conversationId], - conversationSeedFileIds(parent), - ) - : []; - /** - * Cap the dependent messages, shares, and files independently of the parent gap check, and - * before the parent conversion. An already-conforming parent can still own lagging children - * (permanent shares or later-window files created before the mode switch, or left by a partial - * earlier backfill), and a child failure must leave the parent non-conforming so a later - * forced-retention write re-runs the whole cascade. Each cap is an indexed no-op once the - * chat's children conform. - */ - await forceConversationMessagesTemporary(Message, userId, conversationId, expiredAt); - await capConversationSharedLinks(SharedLink, userId, conversationId, expiredAt); - await capConversationFiles(File, userId, conversationId, expiredAt, fileIds); - if (!needsConversion) { - return false; - } - const convoResult = await Conversation.updateOne( - { conversationId, user: userId, ...forcedRetentionGapFilter(expiredAt) }, - { $set: { isTemporary: true, expiredAt } }, - ); - return (convoResult.modifiedCount ?? 0) > 0; -}; - -/** - * Bulk-applies forced retention to the user's conversations selected by `conversationMatch` - * (a bookmark tag, a chat project, etc.). Writes that touch these rows directly - * (`Conversation.updateMany`) without setting `isTemporary`/`expiredAt` would otherwise leave a - * permanent chat visible and non-expiring after an install switched to ephemeral. Chats are - * bucketed by their capped deadline so each bucket converts the chats, backfills their messages, - * and caps their shares and files in one pass; the gap filter keeps it a no-op for chats that - * already conform and never extends a chat that already expires sooner. - */ -const cascadeForcedRetentionForConversationSet = async ( - Conversation: Model, - Message: Model, - SharedLink: Model, - File: Model, - userId: string, - conversationMatch: FilterQuery, - forcedExpiredAt: Date, + tags: string[], ): Promise => { - const conversations = await Conversation.find( - { user: userId, ...conversationMatch } as FilterQuery, - 'conversationId isTemporary expiredAt files file_ids', - ).lean< - Array< - RetentionFilterDocument & { conversationId?: string; files?: string[]; file_ids?: string[] } - > - >(); - if (conversations.length === 0) { + const uniqueTags = [...new Set(tags.filter(Boolean))]; + if (uniqueTags.length === 0) { return; } - - const retentionBuckets = new Map< - number, - { expiredAt: Date; conversationIds: string[]; seedFileIds: string[] } - >(); - for (const convo of conversations) { - if (typeof convo.conversationId !== 'string' || convo.conversationId.length === 0) { - continue; - } - - const expiredAt = capForcedRetentionExpiry(convo.expiredAt, forcedExpiredAt); - const key = expiredAt.getTime(); - const bucket = retentionBuckets.get(key) ?? { - expiredAt, - conversationIds: [], - seedFileIds: [], - }; - bucket.conversationIds.push(convo.conversationId); - for (const fileId of conversationSeedFileIds(convo)) { - bucket.seedFileIds.push(fileId); - } - retentionBuckets.set(key, bucket); - } - - for (const { expiredAt, conversationIds, seedFileIds } of retentionBuckets.values()) { - await Conversation.updateMany( - { + const counts = await Conversation.aggregate<{ _id: string; count: number }>([ + { + $match: { user: userId, - conversationId: { $in: conversationIds }, - ...forcedRetentionGapFilter(expiredAt), + tags: { $in: uniqueTags }, + ...buildRetentionVisibilityFilter(), }, - { $set: { isTemporary: true, expiredAt } }, - ); - await Message.updateMany( - { - user: userId, - conversationId: { $in: conversationIds }, - ...forcedRetentionGapFilter(expiredAt), + }, + { + $project: { + tags: { $setIntersection: [{ $ifNull: ['$tags', []] }, uniqueTags] }, }, - [ - { - $set: { - isTemporary: true, - expiredAt: { $min: [{ $ifNull: ['$expiredAt', expiredAt] }, expiredAt] }, - }, - }, - ], - ); - await SharedLink.updateMany( - { - user: userId, - conversationId: { $in: conversationIds }, - $or: [{ expiredAt: null }, { expiredAt: { $gt: expiredAt } }], + }, + { $unwind: '$tags' }, + { $group: { _id: '$tags', count: { $sum: 1 } } }, + ]); + const countByTag = new Map(counts.map(({ _id, count }) => [_id, count])); + const ConversationTag = Conversation.db.models.ConversationTag as Model; + await tenantSafeBulkWrite( + ConversationTag, + uniqueTags.map((tag) => ({ + updateOne: { + filter: { user: userId, tag }, + update: { $set: { count: countByTag.get(tag) ?? 0 } }, }, - { $set: { expiredAt } }, - ); - const fileIds = await collectConversationFileIds(Message, userId, conversationIds, seedFileIds); - const fileScope = ownedFileScope(userId, { $in: conversationIds }, fileIds); - if (fileScope == null) { - continue; - } - await File.updateMany( - { - $and: [fileScope, { $or: [{ expiredAt: null }, { expiredAt: { $gt: expiredAt } }] }], - } as FilterQuery, - { $set: { expiredAt } }, - ); - } + })), + ); }; -/** - * Bulk-applies forced retention to every conversation carrying a bookmark tag. A tag rename or - * delete writes conversation rows directly without setting `isTemporary`/`expiredAt`, so a - * permanent chat tagged before the install switched to ephemeral would otherwise stay visible - * and never expire. - */ -export const cascadeForcedRetentionByTag = ( - Conversation: Model, - Message: Model, - SharedLink: Model, - File: Model, - userId: string, - tag: string, - forcedExpiredAt: Date, -): Promise => - cascadeForcedRetentionForConversationSet( - Conversation, - Message, - SharedLink, - File, - userId, - { tags: tag } as FilterQuery, - forcedExpiredAt, - ); - -/** - * Bulk-applies forced retention to every conversation in a chat project. Assigning a chat to a - * project, removing it, or deleting the project rewrites conversation rows without setting - * `isTemporary`/`expiredAt`, so a permanent chat organized after the install switched to - * ephemeral would otherwise stay visible and never expire. - */ -export const cascadeForcedRetentionByProject = ( - Conversation: Model, - Message: Model, - SharedLink: Model, - File: Model, - userId: string, - chatProjectId: string, - forcedExpiredAt: Date, -): Promise => - cascadeForcedRetentionForConversationSet( - Conversation, - Message, - SharedLink, - File, - userId, - { chatProjectId } as FilterQuery, - forcedExpiredAt, - ); - -/** - * One-time backfill of forced (ephemeral) retention over pre-existing data. Convert-on-touch - * only converts conversations that are subsequently written, so enabling ephemeral mode on a - * deployment with existing chats leaves untouched permanent rows visible and non-expiring. - * - * Streams every conversation that does not yet conform to the forced window and converts it, - * its messages, its shares, and its uploaded files one conversation at a time. Each conversation - * is capped to the earlier of its own deadline and the forced window, and its messages, shares, - * and files are capped to that same per-conversation deadline, so the sweep never extends data - * that already expires sooner and never lets a dependent record outlive its conversation. It is - * idempotent: re-running skips conversations that already conform. - * - * Converted conversations become `isTemporary: true`, which `visibleProjectConversationFilter` - * hides, so each converted chat's project membership is collected in `projects` for the caller - * to recompute cached project stats (the sweep cannot refresh them itself without a circular - * dependency on the chat-project methods). - * - * Already-conforming temporary conversations are swept too: their dependent shares, files, and - * messages can still lag (permanent shares or later-window records created before the mode - * switch), so an alignment pass caps those children to each parent's own deadline. `aligned` - * counts the conversations whose children needed changes. - */ -export const sweepForcedRetention = async ( - Conversation: Model, - Message: Model, - SharedLink: Model, - File: Model, - forcedExpiredAt: Date, -): Promise<{ - conversations: number; - aligned: number; - errors: number; - projects: Array<{ user: string; chatProjectId: string }>; -}> => { - const result = { conversations: 0, aligned: 0, errors: 0 }; - const projectKeys = new Set(); - const projects: Array<{ user: string; chatProjectId: string }> = []; - +export function createApplyForcedRetention( + mongoose: typeof import('mongoose'), + { logger, refreshProjectStats }: ApplyForcedRetentionDependencies, +): ApplyForcedRetention { /** - * Alignment pass first: conforming parents are excluded from the gap-filtered conversion - * cursor below, and running this before the conversion avoids revisiting the conversations - * that pass converts (their children are capped at conversion time). + * Applies the complete forced-retention invariant for one owned conversation. + * + * `expiredAt` is a monotonic cap: the conversation and every dependent record keep an + * earlier existing deadline, while null/missing/later deadlines move to the configured + * forced deadline. Every expiry mutation is a server-side aggregation-pipeline `$min`; + * no value derived from a prior read is ever written back with `$set`. + * + * The operation is idempotent and always aligns every child, even when the parent already + * conforms. A partial failure therefore needs only another call; parent conformity is never + * used as a sentinel that would suppress retrying child work. */ - const alignCursor = Conversation.find({ - isTemporary: true, - expiredAt: { $ne: null, $lte: forcedExpiredAt }, - } as FilterQuery) - .select('conversationId user expiredAt files file_ids') - .lean() - .cursor(); - - for await (const convo of alignCursor) { - const { conversationId, user, expiredAt } = convo; + async function applyForcedRetention( + conversationId: string, + userId: string, + appConfig?: AppConfig['interfaceConfig'], + ): Promise { + if (appConfig?.retentionMode !== RetentionMode.EPHEMERAL) { + return null; + } if ( typeof conversationId !== 'string' || - !conversationId || - !user || - !(expiredAt instanceof Date) + conversationId.length === 0 || + typeof userId !== 'string' || + userId.length === 0 ) { - continue; + return null; } - try { - const fileIds = await collectConversationFileIds( - Message, - user, - [conversationId], - conversationSeedFileIds(convo), - ); - const changed = - (await forceConversationMessagesTemporary(Message, user, conversationId, expiredAt)) + - (await capConversationSharedLinks(SharedLink, user, conversationId, expiredAt)) + - (await capConversationFiles(File, user, conversationId, expiredAt, fileIds)); - if (changed > 0) { - result.aligned += 1; - } - } catch { - result.errors += 1; + + const proposedExpiredAt = resolveForcedRetentionDate(appConfig, logger); + const Conversation = mongoose.models.Conversation as Model; + const Message = mongoose.models.Message as Model; + const SharedLink = mongoose.models.SharedLink as Model; + const File = mongoose.models.File as Model; + const parent = await Conversation.findOneAndUpdate( + { conversationId, user: userId }, + expirationPipeline(proposedExpiredAt, true), + { + new: true, + projection: { + expiredAt: 1, + files: 1, + file_ids: 1, + tags: 1, + chatProjectId: 1, + }, + }, + ).lean | null>(); + const expiredAt = parent?.expiredAt instanceof Date ? parent.expiredAt : proposedExpiredAt; + const [fileIds, sharedLinks] = await Promise.all([ + collectReferencedFileIds(Message, userId, conversationId, parent ?? {}), + SharedLink.find({ conversationId, user: userId }, '_id').lean< + Array<{ _id: Types.ObjectId }> + >(), + ]); + const sharedLinkIds = sharedLinks.map(({ _id }) => _id); + const fileScope = createOwnedFileScope(userId, conversationId, fileIds); + const AclEntry = SharedLink.db.models.AclEntry as Model; + + await Promise.all([ + Message.updateMany({ conversationId, user: userId }, expirationPipeline(expiredAt, true)), + ...(sharedLinkIds.length > 0 + ? [ + SharedLink.updateMany({ _id: { $in: sharedLinkIds } }, expirationPipeline(expiredAt)), + AclEntry.updateMany( + { + resourceType: ResourceType.SHARED_LINK, + resourceId: { $in: sharedLinkIds }, + }, + expirationPipeline(expiredAt), + ), + ] + : []), + ...(fileScope != null ? [File.updateMany(fileScope, expirationPipeline(expiredAt))] : []), + ]); + + await reconcileTagCounts(Conversation, userId, parent?.tags ?? []); + if (typeof parent?.chatProjectId === 'string' && parent.chatProjectId.length > 0) { + await refreshProjectStats(userId, parent.chatProjectId); } + return expiredAt; } - const cursor = Conversation.find(forcedRetentionGapFilter(forcedExpiredAt)) - .select('_id conversationId user expiredAt chatProjectId files file_ids') - .lean() - .cursor(); - - for await (const convo of cursor) { - const { conversationId, user, chatProjectId } = convo; - if (typeof conversationId !== 'string' || !conversationId || !user) { - continue; - } - try { - const expiredAt = capForcedRetentionExpiry(convo.expiredAt, forcedExpiredAt); - const fileIds = await collectConversationFileIds( - Message, - user, - [conversationId], - conversationSeedFileIds(convo), - ); - /** - * Convert the dependent messages, shares, and files before marking the conversation itself - * conforming. If a child backfill throws, the conversation stays non-conforming so the - * gap-filtered query picks it up again on a re-run, keeping the sweep safe to repeat. - */ - await forceConversationMessagesTemporary(Message, user, conversationId, expiredAt); - await capConversationSharedLinks(SharedLink, user, conversationId, expiredAt); - await capConversationFiles(File, user, conversationId, expiredAt, fileIds); - const convoResult = await Conversation.updateOne( - { _id: convo._id, ...forcedRetentionGapFilter(expiredAt) }, - { $set: { isTemporary: true, expiredAt } }, - ); - if ((convoResult.modifiedCount ?? 0) === 0) { - continue; - } - result.conversations += 1; - if (typeof chatProjectId === 'string' && chatProjectId.length > 0) { - const key = `${user}|${chatProjectId}`; - if (!projectKeys.has(key)) { - projectKeys.add(key); - projects.push({ user: String(user), chatProjectId }); - } - } - } catch { - result.errors += 1; - } - } - - return { ...result, projects }; -}; + return applyForcedRetention; +}