mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
refactor: centralize ephemeral retention enforcement
This commit is contained in:
parent
e10c9ba834
commit
6d3a0b955d
31 changed files with 681 additions and 2354 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}` },
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -293,7 +293,6 @@ describe('Agent Abort Endpoint', () => {
|
|||
}),
|
||||
expect.objectContaining({
|
||||
context: 'api/server/routes/agents/index.js - abort endpoint',
|
||||
capExpiryToConversation: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<RetentionExpiry> {
|
||||
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<RetentionExpiry> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<IMessage>;
|
||||
SharedLink = mongoose.models.SharedLink as mongoose.Model<ISharedLink>;
|
||||
File = mongoose.models.File as mongoose.Model<IMongoFile>;
|
||||
methods = createChatProjectMethods(mongoose);
|
||||
const applyForcedRetention = createApplyForcedRetention(mongoose, {
|
||||
logger,
|
||||
refreshProjectStats: (userId, projectId) =>
|
||||
refreshChatProjectStatsForUser(mongoose, userId, projectId),
|
||||
});
|
||||
methods = createChatProjectMethods(mongoose, applyForcedRetention);
|
||||
|
||||
await mongoose.connect(mongoUri);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
if (!isForcedTemporaryRetention(interfaceConfig?.retentionMode)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const Conversation = mongoose.models.Conversation as Model<IConversation>;
|
||||
const Message = mongoose.models.Message as Model<IMessage>;
|
||||
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
|
||||
const File = mongoose.models.File as Model<IMongoFile>;
|
||||
return cascadeForcedRetentionByProject(
|
||||
Conversation,
|
||||
Message,
|
||||
SharedLink,
|
||||
File,
|
||||
user,
|
||||
chatProjectId,
|
||||
resolveForcedRetentionDate(interfaceConfig),
|
||||
);
|
||||
}
|
||||
|
||||
async function forceConversationRetention(
|
||||
user: string,
|
||||
conversationId: string,
|
||||
interfaceConfig?: AppConfig['interfaceConfig'],
|
||||
): Promise<void> {
|
||||
if (!isForcedTemporaryRetention(interfaceConfig?.retentionMode)) {
|
||||
return;
|
||||
}
|
||||
const Conversation = mongoose.models.Conversation as Model<IConversation>;
|
||||
const Message = mongoose.models.Message as Model<IMessage>;
|
||||
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
|
||||
const File = mongoose.models.File as Model<IMongoFile>;
|
||||
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<Array<Pick<IConversation, 'conversationId'>>>();
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<ISharedLink>;
|
||||
const AclEntry = mongoose.models.AclEntry as mongoose.Model<IAclEntry>;
|
||||
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<IMongoFile>();
|
||||
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<IConversation>({ conversationId }).lean();
|
||||
expect(stillPermanent?.isTemporary ?? null).not.toBe(true);
|
||||
expect(stillPermanent?.expiredAt ?? null).toBeNull();
|
||||
const convertedParent = await Conversation.findOne<IConversation>({
|
||||
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<IMongoFile>,
|
||||
update: mongoose.UpdateQuery<IMongoFile>,
|
||||
) => {
|
||||
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<IConversation>();
|
||||
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<IMessage>();
|
||||
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<ISharedLink>;
|
||||
const AclEntry = mongoose.models.AclEntry as mongoose.Model<IAclEntry>;
|
||||
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());
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<MessageMethods, 'getMessages' | 'deleteMessages'>,
|
||||
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<IMessage>;
|
||||
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
|
||||
const File = mongoose.models.File as Model<IMongoFile>;
|
||||
/**
|
||||
* 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' &&
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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<IMongoFile>();
|
||||
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<IMongoFile>();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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<IMessage> & { newMessageId?: string },
|
||||
metadata?: { context?: string; capExpiryToConversation?: boolean },
|
||||
metadata?: { context?: string },
|
||||
): Promise<IMessage | null | undefined>;
|
||||
bulkSaveMessages(
|
||||
messages: Array<Partial<IMessage>>,
|
||||
|
|
@ -54,16 +48,6 @@ export interface MessageMethods {
|
|||
message: Partial<IMessage> & { newMessageId?: string },
|
||||
metadata?: { context?: string },
|
||||
): Promise<Partial<IMessage>>;
|
||||
applyForcedRetention(
|
||||
ctx: { userId: string; interfaceConfig?: AppConfig['interfaceConfig'] },
|
||||
params: { conversationId: string; messageId?: string },
|
||||
metadata?: { context?: string; capExpiryToConversation?: boolean },
|
||||
): Promise<void>;
|
||||
applyForcedRetentionToTag(
|
||||
ctx: { userId: string; interfaceConfig?: AppConfig['interfaceConfig'] },
|
||||
params: { tag: string },
|
||||
metadata?: { context?: string },
|
||||
): Promise<void>;
|
||||
deleteMessagesSince(
|
||||
userId: string,
|
||||
params: { messageId: string; conversationId: string },
|
||||
|
|
@ -91,7 +75,10 @@ export interface MessageMethods {
|
|||
deleteMessages(filter: FilterQuery<IMessage>): Promise<DeleteResult>;
|
||||
}
|
||||
|
||||
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<IMessage> & { 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<IConversation>;
|
||||
if (metadata?.capExpiryToConversation === true) {
|
||||
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
|
||||
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<IConversation>;
|
||||
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
|
||||
const File = mongoose.models.File as Model<IMongoFile>;
|
||||
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<IConversation>);
|
||||
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<IConversation>,
|
||||
): Promise<void> {
|
||||
const Conversation = mongoose.models.Conversation as Model<IConversation>;
|
||||
const projectChats = await Conversation.find(
|
||||
{ user: userId, ...conversationFilter, chatProjectId: { $exists: true, $ne: null } },
|
||||
'chatProjectId',
|
||||
).lean<Array<{ chatProjectId?: string | null }>>();
|
||||
if (projectChats.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const projectIds = new Set<string>();
|
||||
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<void> {
|
||||
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<IMessage>;
|
||||
const Conversation = mongoose.models.Conversation as Model<IConversation>;
|
||||
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
|
||||
const File = mongoose.models.File as Model<IMongoFile>;
|
||||
|
||||
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<IConversation>);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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<IMessage>;
|
||||
const Conversation = mongoose.models.Conversation as Model<IConversation>;
|
||||
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
|
||||
const File = mongoose.models.File as Model<IMongoFile>;
|
||||
|
||||
await cascadeForcedRetentionByTag(
|
||||
Conversation,
|
||||
Message,
|
||||
SharedLink,
|
||||
File,
|
||||
userId,
|
||||
tag,
|
||||
forcedExpiredAt,
|
||||
);
|
||||
await refreshForcedRetentionProjectStats(userId, { tags: tag } as FilterQuery<IConversation>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
|
|
|||
|
|
@ -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<unknown>;
|
||||
};
|
||||
|
||||
export type ApplyForcedRetention = (
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
appConfig?: AppConfig['interfaceConfig'],
|
||||
) => Promise<Date | null>;
|
||||
|
||||
export const activeExpirationFilter = <
|
||||
T extends RetentionFilterDocument = RetentionFilterDocument,
|
||||
>(): FilterQuery<T> =>
|
||||
|
|
@ -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<T> =>
|
||||
({
|
||||
$or: [
|
||||
{ isTemporary: { $ne: true } },
|
||||
{ expiredAt: null },
|
||||
{ expiredAt: { $gt: forcedExpiredAt } },
|
||||
],
|
||||
}) as FilterQuery<T>;
|
||||
|
||||
/**
|
||||
* 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<IMessage>,
|
||||
userId: string,
|
||||
conversationId: string,
|
||||
expiredAt: Date,
|
||||
): Promise<number> => {
|
||||
const result = await Message.updateMany(
|
||||
{ conversationId, user: userId, ...forcedRetentionGapFilter<IMessage>(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<ISharedLink>,
|
||||
userId: string,
|
||||
conversationId: string,
|
||||
forcedExpiredAt: Date,
|
||||
): Promise<number> => {
|
||||
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<IMessage>,
|
||||
userId: string,
|
||||
conversationIds: string[],
|
||||
seedFileIds?: Iterable<string | null | undefined>,
|
||||
parent: Pick<IConversation, 'files' | 'file_ids'>,
|
||||
): Promise<string[]> => {
|
||||
const fileIds = new Set<string>();
|
||||
const addFileIds = (references?: Array<{ file_id?: unknown } | null>) => {
|
||||
const fileIds = new Set<string>([...(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<IMongoFile> | 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<IMongoFile>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<IMongoFile>,
|
||||
userId: string,
|
||||
conversationId: string,
|
||||
forcedExpiredAt: Date,
|
||||
fileIds: string[] = [],
|
||||
): Promise<number> => {
|
||||
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<IMongoFile>,
|
||||
{ $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<IConversation>,
|
||||
Message: Model<IMessage>,
|
||||
SharedLink: Model<ISharedLink>,
|
||||
userId: string,
|
||||
conversationId: string,
|
||||
forcedExpiredAt: Date,
|
||||
): Promise<Date> => {
|
||||
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<IConversation>,
|
||||
Message: Model<IMessage>,
|
||||
SharedLink: Model<ISharedLink>,
|
||||
File: Model<IMongoFile>,
|
||||
userId: string,
|
||||
conversationId: string,
|
||||
forcedExpiredAt: Date,
|
||||
): Promise<boolean> => {
|
||||
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<IConversation>(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<IConversation>,
|
||||
Message: Model<IMessage>,
|
||||
SharedLink: Model<ISharedLink>,
|
||||
File: Model<IMongoFile>,
|
||||
userId: string,
|
||||
conversationMatch: FilterQuery<IConversation>,
|
||||
forcedExpiredAt: Date,
|
||||
tags: string[],
|
||||
): Promise<void> => {
|
||||
const conversations = await Conversation.find(
|
||||
{ user: userId, ...conversationMatch } as FilterQuery<IConversation>,
|
||||
'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<IConversation>(expiredAt),
|
||||
tags: { $in: uniqueTags },
|
||||
...buildRetentionVisibilityFilter<IConversation>(),
|
||||
},
|
||||
{ $set: { isTemporary: true, expiredAt } },
|
||||
);
|
||||
await Message.updateMany(
|
||||
{
|
||||
user: userId,
|
||||
conversationId: { $in: conversationIds },
|
||||
...forcedRetentionGapFilter<IMessage>(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<IConversationTag>;
|
||||
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<IMongoFile>,
|
||||
{ $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<IConversation>,
|
||||
Message: Model<IMessage>,
|
||||
SharedLink: Model<ISharedLink>,
|
||||
File: Model<IMongoFile>,
|
||||
userId: string,
|
||||
tag: string,
|
||||
forcedExpiredAt: Date,
|
||||
): Promise<void> =>
|
||||
cascadeForcedRetentionForConversationSet(
|
||||
Conversation,
|
||||
Message,
|
||||
SharedLink,
|
||||
File,
|
||||
userId,
|
||||
{ tags: tag } as FilterQuery<IConversation>,
|
||||
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<IConversation>,
|
||||
Message: Model<IMessage>,
|
||||
SharedLink: Model<ISharedLink>,
|
||||
File: Model<IMongoFile>,
|
||||
userId: string,
|
||||
chatProjectId: string,
|
||||
forcedExpiredAt: Date,
|
||||
): Promise<void> =>
|
||||
cascadeForcedRetentionForConversationSet(
|
||||
Conversation,
|
||||
Message,
|
||||
SharedLink,
|
||||
File,
|
||||
userId,
|
||||
{ chatProjectId } as FilterQuery<IConversation>,
|
||||
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<IConversation>,
|
||||
Message: Model<IMessage>,
|
||||
SharedLink: Model<ISharedLink>,
|
||||
File: Model<IMongoFile>,
|
||||
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<string>();
|
||||
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<IConversation>)
|
||||
.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<Date | null> {
|
||||
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<IConversation>;
|
||||
const Message = mongoose.models.Message as Model<IMessage>;
|
||||
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
|
||||
const File = mongoose.models.File as Model<IMongoFile>;
|
||||
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<Pick<
|
||||
IConversation,
|
||||
'expiredAt' | 'files' | 'file_ids' | 'tags' | 'chatProjectId'
|
||||
> | 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<IAclEntry>;
|
||||
|
||||
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<IConversation>(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<IConversation>(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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue