mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
refactor: reduce ephemeral retention to forced temporary chats
This commit is contained in:
parent
744e850feb
commit
7378f1ec03
32 changed files with 211 additions and 2158 deletions
|
|
@ -54,7 +54,9 @@ const sendError = async (req, res, options, callback) => {
|
|||
interfaceConfig: req?.config?.interfaceConfig,
|
||||
},
|
||||
{ ...errorMessage, user },
|
||||
{ context: 'api/server/utils/streamResponse.js - sendError' },
|
||||
{
|
||||
context: 'api/server/utils/streamResponse.js - sendError',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -72,8 +72,8 @@ module.exports = {
|
|||
forkIpLimiter: (req, res, next) => next(),
|
||||
forkUserLimiter: (req, res, next) => next(),
|
||||
})),
|
||||
configMiddleware: jest.fn((req, res, next) => next()),
|
||||
validateConvoAccess: jest.fn((req, res, next) => next()),
|
||||
configMiddleware: (req, res, next) => next(),
|
||||
validateConvoAccess: (req, res, next) => next(),
|
||||
}),
|
||||
|
||||
forkUtils: () => ({
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ describe('Convos Routes', () => {
|
|||
deleteAllSharedLinksWithCleanup,
|
||||
deleteConvoSharedLinksWithCleanup,
|
||||
} = require('@librechat/api');
|
||||
const { configMiddleware, validateConvoAccess } = require('~/server/middleware');
|
||||
|
||||
beforeAll(() => {
|
||||
convosRouter = require('../convos');
|
||||
|
|
@ -48,30 +47,6 @@ describe('Convos Routes', () => {
|
|||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('conversation mutation middleware', () => {
|
||||
it.each([
|
||||
['/api/convos/archive', { arg: { conversationId: 'conv-123', isArchived: true } }],
|
||||
['/api/convos/pin', { arg: { conversationId: 'conv-123', pinned: true } }],
|
||||
['/api/convos/update', { arg: { conversationId: 'conv-123', title: 'Updated' } }],
|
||||
])('loads config before an access denial on %s', async (path, body) => {
|
||||
configMiddleware.mockImplementationOnce((req, res, next) => {
|
||||
req.config = { interfaceConfig: { retentionMode: 'ephemeral' } };
|
||||
next();
|
||||
});
|
||||
validateConvoAccess.mockImplementationOnce((req, res) => {
|
||||
res.status(403).json({ configLoaded: req.config != null });
|
||||
});
|
||||
|
||||
const response = await request(app).post(path).send(body);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toEqual({ configLoaded: true });
|
||||
expect(configMiddleware).toHaveBeenCalledTimes(1);
|
||||
expect(validateConvoAccess).toHaveBeenCalledTimes(1);
|
||||
expect(saveConvo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /all', () => {
|
||||
it('prunes the deleted conversations’ agent checkpoints (bulk, ids from deleteConvos)', async () => {
|
||||
// HITL: a paused conversation's durable checkpoint must not outlive the conversation.
|
||||
|
|
@ -614,7 +589,7 @@ describe('Convos Routes', () => {
|
|||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockPinnedConvo);
|
||||
expect(saveConvo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: 'test-user-123' }),
|
||||
{ userId: 'test-user-123' },
|
||||
{ conversationId: mockConversationId, pinned: true },
|
||||
{ context: `POST /api/convos/pin ${mockConversationId}` },
|
||||
);
|
||||
|
|
@ -629,7 +604,7 @@ describe('Convos Routes', () => {
|
|||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUnpinnedConvo);
|
||||
expect(saveConvo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: 'test-user-123' }),
|
||||
{ userId: 'test-user-123' },
|
||||
{ conversationId: mockConversationId, pinned: false },
|
||||
{ context: `POST /api/convos/pin ${mockConversationId}` },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ jest.mock('~/models', () => ({
|
|||
getMessages: jest.fn(),
|
||||
updateMessage: jest.fn(),
|
||||
deleteMessages: jest.fn(),
|
||||
applyForcedRetention: jest.fn(),
|
||||
getConvosQueried: jest.fn(),
|
||||
searchMessages: jest.fn(),
|
||||
getMessagesByCursor: jest.fn(),
|
||||
|
|
@ -71,10 +70,7 @@ jest.mock('~/server/middleware', () => {
|
|||
validateMessageReq,
|
||||
sendValidationResponse,
|
||||
prepareMessageRequestValidation,
|
||||
configMiddleware: (req, res, next) => {
|
||||
req.config = { interfaceConfig: { retentionMode: 'ephemeral' } };
|
||||
next();
|
||||
},
|
||||
configMiddleware: (req, res, next) => next(),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -169,7 +165,7 @@ describe('deleteMessages – model-level IDOR prevention', () => {
|
|||
|
||||
describe('DELETE /:conversationId/:messageId – route handler', () => {
|
||||
let app;
|
||||
const { deleteMessages, applyForcedRetention } = require('~/models');
|
||||
const { deleteMessages } = require('~/models');
|
||||
|
||||
const authenticatedUserId = 'user-owner-123';
|
||||
|
||||
|
|
@ -213,14 +209,6 @@ describe('DELETE /:conversationId/:messageId – route handler', () => {
|
|||
conversationId: 'convo-1',
|
||||
user: authenticatedUserId,
|
||||
});
|
||||
expect(applyForcedRetention).toHaveBeenCalledWith(
|
||||
'convo-1',
|
||||
authenticatedUserId,
|
||||
expect.objectContaining({ retentionMode: 'ephemeral' }),
|
||||
);
|
||||
expect(applyForcedRetention.mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||
deleteMessages.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 500 when deleteMessages throws', async () => {
|
||||
|
|
@ -230,6 +218,5 @@ describe('DELETE /:conversationId/:messageId – route handler', () => {
|
|||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Internal server error' });
|
||||
expect(applyForcedRetention).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -84,7 +84,6 @@ jest.mock('~/models', () => ({
|
|||
getSharedLink: jest.fn(),
|
||||
getSharedLinkFile: jest.fn(),
|
||||
backfillSharedLinkFiles: jest.fn(),
|
||||
applyForcedRetention: jest.fn(),
|
||||
getRoleByName: jest.fn(),
|
||||
}));
|
||||
|
||||
|
|
@ -138,7 +137,6 @@ const {
|
|||
updateSharedLink,
|
||||
getSharedLinkFile,
|
||||
backfillSharedLinkFiles,
|
||||
applyForcedRetention,
|
||||
getRoleByName,
|
||||
} = require('~/models');
|
||||
const { forkSharedConversation } = require('~/server/utils/import/fork');
|
||||
|
|
@ -358,81 +356,6 @@ describe('share routes', () => {
|
|||
expect(createSharedLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('converts the source conversation under forced retention when creating a share', async () => {
|
||||
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
|
||||
createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' });
|
||||
|
||||
const response = await request(buildApp({ retentionMode: RetentionMode.EPHEMERAL }))
|
||||
.post('/api/share/convo-123')
|
||||
.send({ targetMessageId: 'msg-123' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
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],
|
||||
);
|
||||
});
|
||||
|
||||
it('converts the source conversation before creating the share so retries stay covered', async () => {
|
||||
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
|
||||
createSharedLink.mockResolvedValue(null);
|
||||
|
||||
const response = await request(buildApp({ retentionMode: RetentionMode.EPHEMERAL }))
|
||||
.post('/api/share/convo-123')
|
||||
.send({ targetMessageId: 'msg-123' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
/**
|
||||
* Retention runs before createSharedLink: a share attempt that fails (or hits an existing
|
||||
* 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('convo-123', 'user-123', {
|
||||
retentionMode: RetentionMode.EPHEMERAL,
|
||||
});
|
||||
expect(applyForcedRetention.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
createSharedLink.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('converts the source conversation under forced retention when updating a share', async () => {
|
||||
mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
|
||||
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
|
||||
updateSharedLink.mockResolvedValue({ _id: 'link-456', shareId: 'share-456' });
|
||||
|
||||
await request(buildApp({ retentionMode: RetentionMode.EPHEMERAL }))
|
||||
.patch('/api/share/share-123')
|
||||
.send({ snapshotFiles: false });
|
||||
|
||||
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 () => {
|
||||
mockGetSharedLinkExpiration.mockResolvedValue(expiredExpiration);
|
||||
createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' });
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ router.delete('/all', configMiddleware, async (req, res) => {
|
|||
* @param {boolean} req.body.arg.isArchived - Whether to archive (true) or unarchive (false).
|
||||
* @returns {object} 200 - The updated conversation object.
|
||||
*/
|
||||
router.post('/archive', configMiddleware, validateConvoAccess, async (req, res) => {
|
||||
router.post('/archive', validateConvoAccess, async (req, res) => {
|
||||
const { conversationId, isArchived } = req.body?.arg ?? {};
|
||||
|
||||
if (!conversationId) {
|
||||
|
|
@ -214,7 +214,7 @@ router.post('/archive', configMiddleware, validateConvoAccess, async (req, res)
|
|||
}
|
||||
});
|
||||
|
||||
router.post('/pin', configMiddleware, validateConvoAccess, async (req, res) => {
|
||||
router.post('/pin', validateConvoAccess, async (req, res) => {
|
||||
const { conversationId, pinned } = req.body?.arg ?? {};
|
||||
|
||||
if (!conversationId) {
|
||||
|
|
@ -231,11 +231,7 @@ router.post('/pin', configMiddleware, validateConvoAccess, async (req, res) => {
|
|||
|
||||
try {
|
||||
const dbResponse = await db.saveConvo(
|
||||
{
|
||||
userId: req.user.id,
|
||||
isTemporary: req?.body?.isTemporary,
|
||||
interfaceConfig: req?.config?.interfaceConfig,
|
||||
},
|
||||
{ userId: req.user.id },
|
||||
{ conversationId, pinned },
|
||||
{ context: `POST /api/convos/pin ${conversationId}` },
|
||||
);
|
||||
|
|
@ -256,7 +252,7 @@ const MAX_CONVO_TITLE_LENGTH = 1024;
|
|||
* @param {string} req.body.arg.title - The new title for the conversation.
|
||||
* @returns {object} 201 - The updated conversation object.
|
||||
*/
|
||||
router.post('/update', configMiddleware, validateConvoAccess, async (req, res) => {
|
||||
router.post('/update', validateConvoAccess, async (req, res) => {
|
||||
const { conversationId, title } = req.body?.arg ?? {};
|
||||
|
||||
if (!conversationId) {
|
||||
|
|
|
|||
|
|
@ -22,13 +22,6 @@ const db = require('~/models');
|
|||
const router = express.Router();
|
||||
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) =>
|
||||
db.applyForcedRetention(conversationId, req?.user?.id, req?.config?.interfaceConfig);
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const user = req.user.id ?? '';
|
||||
|
|
@ -356,86 +349,79 @@ router.get('/:conversationId/:messageId', validateMessageReq, async (req, res) =
|
|||
}
|
||||
});
|
||||
|
||||
router.put(
|
||||
'/:conversationId/:messageId',
|
||||
validateMessageReq,
|
||||
configMiddleware,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { conversationId, messageId } = req.params;
|
||||
const { text, index, model } = req.body;
|
||||
router.put('/:conversationId/:messageId', validateMessageReq, async (req, res) => {
|
||||
try {
|
||||
const { conversationId, messageId } = req.params;
|
||||
const { text, index, model } = req.body;
|
||||
|
||||
if (index === undefined) {
|
||||
/** A user turn's persisted `quotes` are re-prepended into the prompt on
|
||||
* every send, but this edit only changes `text`. Count the merged
|
||||
* text+quotes so the stored `tokenCount` stays authoritative (matching the
|
||||
* send path); a plain text-only count under-reports by the quote block. */
|
||||
const existing = (
|
||||
await db.getMessages(
|
||||
{ conversationId, messageId, user: req.user.id },
|
||||
'quotes isCreatedByUser',
|
||||
)
|
||||
)?.[0];
|
||||
const textToCount = mergeQuotedTextForCount(
|
||||
text,
|
||||
existing?.quotes,
|
||||
existing?.isCreatedByUser === true,
|
||||
);
|
||||
const tokenCount = await countTokens(textToCount, model);
|
||||
const result = await db.updateMessage(req?.user?.id, { messageId, text, tokenCount });
|
||||
await enforceForcedRetention(req, conversationId);
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
if (typeof index !== 'number' || index < 0) {
|
||||
return res.status(400).json({ error: 'Invalid index' });
|
||||
}
|
||||
|
||||
const message = (
|
||||
await db.getMessages({ conversationId, messageId, user: req.user.id }, 'content tokenCount')
|
||||
if (index === undefined) {
|
||||
/** A user turn's persisted `quotes` are re-prepended into the prompt on
|
||||
* every send, but this edit only changes `text`. Count the merged
|
||||
* text+quotes so the stored `tokenCount` stays authoritative (matching the
|
||||
* send path); a plain text-only count under-reports by the quote block. */
|
||||
const existing = (
|
||||
await db.getMessages(
|
||||
{ conversationId, messageId, user: req.user.id },
|
||||
'quotes isCreatedByUser',
|
||||
)
|
||||
)?.[0];
|
||||
if (!message) {
|
||||
return res.status(404).json({ error: 'Message not found' });
|
||||
}
|
||||
|
||||
const existingContent = message.content;
|
||||
if (!Array.isArray(existingContent) || index >= existingContent.length) {
|
||||
return res.status(400).json({ error: 'Invalid index' });
|
||||
}
|
||||
|
||||
const updatedContent = [...existingContent];
|
||||
if (!updatedContent[index]) {
|
||||
return res.status(400).json({ error: 'Content part not found' });
|
||||
}
|
||||
|
||||
const currentPartType = updatedContent[index].type;
|
||||
if (currentPartType !== ContentTypes.TEXT && currentPartType !== ContentTypes.THINK) {
|
||||
return res.status(400).json({ error: 'Cannot update non-text content' });
|
||||
}
|
||||
|
||||
const oldText = updatedContent[index][currentPartType];
|
||||
updatedContent[index] = { type: currentPartType, [currentPartType]: text };
|
||||
|
||||
let tokenCount = message.tokenCount;
|
||||
if (tokenCount !== undefined) {
|
||||
const oldTokenCount = await countTokens(oldText, model);
|
||||
const newTokenCount = await countTokens(text, model);
|
||||
tokenCount = Math.max(0, tokenCount - oldTokenCount) + newTokenCount;
|
||||
}
|
||||
|
||||
const result = await db.updateMessage(req?.user?.id, {
|
||||
messageId,
|
||||
content: updatedContent,
|
||||
tokenCount,
|
||||
});
|
||||
await enforceForcedRetention(req, conversationId);
|
||||
const textToCount = mergeQuotedTextForCount(
|
||||
text,
|
||||
existing?.quotes,
|
||||
existing?.isCreatedByUser === true,
|
||||
);
|
||||
const tokenCount = await countTokens(textToCount, model);
|
||||
const result = await db.updateMessage(req?.user?.id, { messageId, text, tokenCount });
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
logger.error('Error updating message:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (typeof index !== 'number' || index < 0) {
|
||||
return res.status(400).json({ error: 'Invalid index' });
|
||||
}
|
||||
|
||||
const message = (
|
||||
await db.getMessages({ conversationId, messageId, user: req.user.id }, 'content tokenCount')
|
||||
)?.[0];
|
||||
if (!message) {
|
||||
return res.status(404).json({ error: 'Message not found' });
|
||||
}
|
||||
|
||||
const existingContent = message.content;
|
||||
if (!Array.isArray(existingContent) || index >= existingContent.length) {
|
||||
return res.status(400).json({ error: 'Invalid index' });
|
||||
}
|
||||
|
||||
const updatedContent = [...existingContent];
|
||||
if (!updatedContent[index]) {
|
||||
return res.status(400).json({ error: 'Content part not found' });
|
||||
}
|
||||
|
||||
const currentPartType = updatedContent[index].type;
|
||||
if (currentPartType !== ContentTypes.TEXT && currentPartType !== ContentTypes.THINK) {
|
||||
return res.status(400).json({ error: 'Cannot update non-text content' });
|
||||
}
|
||||
|
||||
const oldText = updatedContent[index][currentPartType];
|
||||
updatedContent[index] = { type: currentPartType, [currentPartType]: text };
|
||||
|
||||
let tokenCount = message.tokenCount;
|
||||
if (tokenCount !== undefined) {
|
||||
const oldTokenCount = await countTokens(oldText, model);
|
||||
const newTokenCount = await countTokens(text, model);
|
||||
tokenCount = Math.max(0, tokenCount - oldTokenCount) + newTokenCount;
|
||||
}
|
||||
|
||||
const result = await db.updateMessage(req?.user?.id, {
|
||||
messageId,
|
||||
content: updatedContent,
|
||||
tokenCount,
|
||||
});
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
logger.error('Error updating message:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put(
|
||||
'/:conversationId/:messageId/feedback',
|
||||
|
|
@ -454,7 +440,6 @@ router.put(
|
|||
},
|
||||
{ context: 'updateFeedback' },
|
||||
);
|
||||
await enforceForcedRetention(req, conversationId);
|
||||
|
||||
// Best-effort: Assistants messages do not have deterministic AgentRun traces.
|
||||
if (!isAssistantsEndpoint(updatedMessage.endpoint)) {
|
||||
|
|
@ -489,21 +474,15 @@ router.put(
|
|||
},
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:conversationId/:messageId',
|
||||
validateMessageReq,
|
||||
configMiddleware,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { conversationId, messageId } = req.params;
|
||||
await db.deleteMessages({ messageId, conversationId, user: req.user.id });
|
||||
await enforceForcedRetention(req, conversationId);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
logger.error('Error deleting message:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
},
|
||||
);
|
||||
router.delete('/:conversationId/:messageId', validateMessageReq, async (req, res) => {
|
||||
try {
|
||||
const { conversationId, messageId } = req.params;
|
||||
await db.deleteMessages({ messageId, conversationId, user: req.user.id });
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
logger.error('Error deleting message:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
const express = require('express');
|
||||
const { createProjectHandlers } = require('@librechat/api');
|
||||
const { requireJwtAuth, configMiddleware } = require('~/server/middleware');
|
||||
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
||||
const db = require('~/models');
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -17,13 +17,9 @@ router.use(requireJwtAuth);
|
|||
|
||||
router.get('/', handlers.listProjects);
|
||||
router.post('/', handlers.createProject);
|
||||
router.put(
|
||||
'/conversations/:conversationId',
|
||||
configMiddleware,
|
||||
handlers.assignConversationToProject,
|
||||
);
|
||||
router.put('/conversations/:conversationId', handlers.assignConversationToProject);
|
||||
router.get('/:projectId', handlers.getProject);
|
||||
router.patch('/:projectId', handlers.updateProject);
|
||||
router.delete('/:projectId', configMiddleware, handlers.deleteProject);
|
||||
router.delete('/:projectId', handlers.deleteProject);
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -21,12 +21,7 @@ const {
|
|||
SYSTEM_TENANT_ID,
|
||||
createTempChatExpirationDate,
|
||||
} = require('@librechat/data-schemas');
|
||||
const {
|
||||
FileSources,
|
||||
PermissionTypes,
|
||||
Permissions,
|
||||
RetentionMode,
|
||||
} = require('librechat-data-provider');
|
||||
const { FileSources, PermissionTypes, Permissions } = require('librechat-data-provider');
|
||||
const {
|
||||
getFiles,
|
||||
updateFile,
|
||||
|
|
@ -37,7 +32,6 @@ const {
|
|||
getSharedLink,
|
||||
getSharedLinkFile,
|
||||
backfillSharedLinkFiles,
|
||||
applyForcedRetention,
|
||||
getRoleByName,
|
||||
} = require('~/models');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
|
|
@ -74,14 +68,6 @@ const resolveSharedLinkExpiration = (req, conversationId) =>
|
|||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Converts the shared source conversation (and its messages) under forced (ephemeral)
|
||||
* 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) =>
|
||||
applyForcedRetention(conversationId, req?.user?.id, req?.config?.interfaceConfig);
|
||||
|
||||
/**
|
||||
* Shared messages
|
||||
*/
|
||||
|
|
@ -468,22 +454,10 @@ router.post(
|
|||
async (req, res) => {
|
||||
try {
|
||||
const { targetMessageId } = req.body;
|
||||
/**
|
||||
* Convert the source conversation before creating the link. createSharedLink rejects
|
||||
* when an active share already exists, so a retention failure after creation would
|
||||
* leave a live share whose source chat never converts — no retry could reach the
|
||||
* cascade again. Converting first also lets the share expiration below read the
|
||||
* converted conversation's deadline.
|
||||
*/
|
||||
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] || {};
|
||||
|
|
@ -496,12 +470,11 @@ router.post(
|
|||
req.user.id,
|
||||
req.params.conversationId,
|
||||
targetMessageId,
|
||||
writeExpiredAt,
|
||||
expiredAt,
|
||||
snapshotFiles,
|
||||
);
|
||||
if (created) {
|
||||
await grantCreationPermissions(created._id, req.user.id, grantPublic, writeExpiredAt);
|
||||
await enforceForcedRetention(req, req.params.conversationId);
|
||||
await grantCreationPermissions(created._id, req.user.id, grantPublic, expiredAt);
|
||||
res.status(200).json(created);
|
||||
} else {
|
||||
res.status(404).end();
|
||||
|
|
@ -532,24 +505,17 @@ 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,
|
||||
writeExpiredAt,
|
||||
expiredAt,
|
||||
isFileSnapshotEnabled(req.config) && req.body?.snapshotFiles !== false,
|
||||
);
|
||||
if (updatedShare) {
|
||||
if (updatedShare._id && writeExpiredAt !== undefined) {
|
||||
await updateSharedLinkPermissionsExpiration(updatedShare._id, writeExpiredAt);
|
||||
}
|
||||
if (existing?.conversationId) {
|
||||
await enforceForcedRetention(req, existing.conversationId);
|
||||
if (updatedShare._id && expiredAt !== undefined) {
|
||||
await updateSharedLinkPermissionsExpiration(updatedShare._id, expiredAt);
|
||||
}
|
||||
res.status(200).json(updatedShare);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
const mongoose = require('mongoose');
|
||||
const express = require('express');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { generateCheckAccess } = require('@librechat/api');
|
||||
|
|
@ -9,10 +8,9 @@ const {
|
|||
createConversationTag,
|
||||
deleteConversationTag,
|
||||
getConversationTags,
|
||||
applyForcedRetention,
|
||||
getRoleByName,
|
||||
} = require('~/models');
|
||||
const { requireJwtAuth, configMiddleware } = require('~/server/middleware');
|
||||
const { requireJwtAuth } = require('~/server/middleware');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
|
@ -25,30 +23,6 @@ const checkBookmarkAccess = generateCheckAccess({
|
|||
router.use(requireJwtAuth);
|
||||
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) =>
|
||||
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 = 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 /
|
||||
* Retrieves all conversation tags for the authenticated user.
|
||||
|
|
@ -75,12 +49,9 @@ router.get('/', async (req, res) => {
|
|||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
*/
|
||||
router.post('/', configMiddleware, async (req, res) => {
|
||||
router.post('/', 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);
|
||||
}
|
||||
res.status(200).json(tag);
|
||||
} catch (error) {
|
||||
logger.error('Error creating conversation tag:', error);
|
||||
|
|
@ -94,17 +65,9 @@ router.post('/', configMiddleware, async (req, res) => {
|
|||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
*/
|
||||
router.put('/:tag', configMiddleware, async (req, res) => {
|
||||
router.put('/:tag', async (req, res) => {
|
||||
try {
|
||||
const decodedTag = decodeURIComponent(req.params.tag);
|
||||
/**
|
||||
* Enforce retention with the old tag before the rename commits. The rename rewrites the
|
||||
* conversations' tag entries, so a cascade failure afterwards would 500 while a retried
|
||||
* PUT /:oldTag hits the 404 path (the old tag no longer exists) and the affected chats
|
||||
* never convert. The old tag selects the same conversations the renamed tag will carry,
|
||||
* and enforcing on a nonexistent tag is a no-op, so a failed rename retries cleanly.
|
||||
*/
|
||||
await enforceForcedRetentionForTag(req, decodedTag, 'PUT /api/tags/:tag');
|
||||
const tag = await updateConversationTag(req.user.id, decodedTag, req.body);
|
||||
if (tag) {
|
||||
res.status(200).json(tag);
|
||||
|
|
@ -123,10 +86,9 @@ router.put('/:tag', configMiddleware, async (req, res) => {
|
|||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
*/
|
||||
router.delete('/:tag', configMiddleware, async (req, res) => {
|
||||
router.delete('/:tag', async (req, res) => {
|
||||
try {
|
||||
const decodedTag = decodeURIComponent(req.params.tag);
|
||||
await enforceForcedRetentionForTag(req, decodedTag, 'DELETE /api/tags/:tag');
|
||||
const tag = await deleteConversationTag(req.user.id, decodedTag);
|
||||
if (tag) {
|
||||
res.status(200).json(tag);
|
||||
|
|
@ -145,14 +107,13 @@ router.delete('/:tag', configMiddleware, async (req, res) => {
|
|||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
*/
|
||||
router.put('/convo/:conversationId', configMiddleware, async (req, res) => {
|
||||
router.put('/convo/:conversationId', async (req, res) => {
|
||||
try {
|
||||
const conversationTags = await updateTagsForConversation(
|
||||
req.user.id,
|
||||
req.params.conversationId,
|
||||
req.body.tags,
|
||||
);
|
||||
await enforceForcedRetention(req, req.params.conversationId);
|
||||
res.status(200).json(conversationTags);
|
||||
} catch (error) {
|
||||
logger.error('Error updating conversation tags', error);
|
||||
|
|
|
|||
|
|
@ -21,11 +21,7 @@ jest.mock('librechat-data-provider', () => {
|
|||
return {
|
||||
...actual,
|
||||
Providers: actual.Providers,
|
||||
RetentionMode: actual.RetentionMode ?? {
|
||||
ALL: 'all',
|
||||
TEMPORARY: 'temporary',
|
||||
EPHEMERAL: 'ephemeral',
|
||||
},
|
||||
RetentionMode: actual.RetentionMode ?? { ALL: 'all', TEMPORARY: 'temporary' },
|
||||
documentParserMimeTypes: actual.documentParserMimeTypes ?? [
|
||||
/^application\/pdf$/,
|
||||
/^application\/vnd\.openxmlformats-officedocument\./,
|
||||
|
|
@ -39,14 +35,7 @@ jest.mock('librechat-data-provider', () => {
|
|||
|
||||
jest.mock('@librechat/api', () => {
|
||||
const actualDataProvider = jest.requireActual('librechat-data-provider');
|
||||
const RetentionMode = actualDataProvider.RetentionMode ?? {
|
||||
ALL: 'all',
|
||||
TEMPORARY: 'temporary',
|
||||
EPHEMERAL: 'ephemeral',
|
||||
};
|
||||
const isAllDataRetention =
|
||||
actualDataProvider.isAllDataRetention ??
|
||||
((mode) => mode === RetentionMode.ALL || mode === RetentionMode.EPHEMERAL);
|
||||
const RetentionMode = actualDataProvider.RetentionMode ?? { ALL: 'all', TEMPORARY: 'temporary' };
|
||||
const getRetentionExpiry = jest.fn(() => ({}));
|
||||
return {
|
||||
sanitizeFilename: jest.fn((n) => n),
|
||||
|
|
@ -63,12 +52,11 @@ jest.mock('@librechat/api', () => {
|
|||
getRetentionExpiry,
|
||||
getAgentFileRetentionExpiry: jest.fn(({ req, messageAttachment, toolResource }) => {
|
||||
const interfaceConfig = req?.config?.interfaceConfig;
|
||||
const retentionMode = interfaceConfig?.retentionMode;
|
||||
if (
|
||||
!messageAttachment &&
|
||||
!!toolResource &&
|
||||
(!isAllDataRetention(retentionMode) ||
|
||||
(retentionMode === RetentionMode.ALL && interfaceConfig?.retainAgentFiles === true))
|
||||
(interfaceConfig?.retentionMode !== RetentionMode.ALL ||
|
||||
interfaceConfig?.retainAgentFiles === true)
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
|
@ -655,31 +643,6 @@ describe('processAgentFileUpload', () => {
|
|||
);
|
||||
});
|
||||
|
||||
test('applies ephemeral retention metadata to persistent agent context files when retainAgentFiles is enabled', async () => {
|
||||
const expiredAt = new Date('2030-01-01T00:00:00.000Z');
|
||||
getRetentionExpiry.mockResolvedValueOnce({ expiredAt });
|
||||
const req = makeReq({
|
||||
mimetype: PDF_MIME,
|
||||
ocrConfig: null,
|
||||
interfaceConfig: {
|
||||
retentionMode: RetentionMode.EPHEMERAL,
|
||||
retainAgentFiles: true,
|
||||
},
|
||||
});
|
||||
|
||||
await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
|
||||
|
||||
expect(getRetentionExpiry).toHaveBeenCalledTimes(1);
|
||||
expect(getRetentionExpiry.mock.calls[0][0]).toBe(req);
|
||||
expect(db.createFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
expiredAt,
|
||||
context: FileContext.agents,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('applies retention metadata to context files uploaded as message attachments', async () => {
|
||||
const expiredAt = new Date('2030-01-01T00:00:00.000Z');
|
||||
getRetentionExpiry.mockResolvedValueOnce({ expiredAt });
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
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(),
|
||||
|
|
@ -38,7 +37,6 @@ const {
|
|||
cloneMessagesWithTimestamps,
|
||||
} = require('./fork');
|
||||
const {
|
||||
applyForcedRetention,
|
||||
bulkIncrementTagCounts,
|
||||
getConvo,
|
||||
bulkSaveConvos,
|
||||
|
|
@ -118,10 +116,17 @@ describe('forkConversation', () => {
|
|||
interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL, temporaryChatRetention: 1 },
|
||||
});
|
||||
|
||||
expect(applyForcedRetention).toHaveBeenCalledWith(expect.any(String), 'user1', {
|
||||
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,
|
||||
);
|
||||
});
|
||||
|
||||
test('should fork conversation without branches', async () => {
|
||||
|
|
@ -288,10 +293,17 @@ describe('duplicateConversation', () => {
|
|||
interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL, temporaryChatRetention: 1 },
|
||||
});
|
||||
|
||||
expect(applyForcedRetention).toHaveBeenCalledWith(expect.any(String), 'user1', {
|
||||
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,
|
||||
);
|
||||
});
|
||||
|
||||
test('should duplicate conversation and increment tag counts', async () => {
|
||||
|
|
|
|||
|
|
@ -7,16 +7,11 @@ const {
|
|||
const {
|
||||
EModelEndpoint,
|
||||
Constants,
|
||||
RetentionMode,
|
||||
openAISettings,
|
||||
isAllDataRetention,
|
||||
isForcedTemporaryRetention,
|
||||
} = require('librechat-data-provider');
|
||||
const {
|
||||
applyForcedRetention,
|
||||
bulkIncrementTagCounts,
|
||||
bulkSaveConvos,
|
||||
bulkSaveMessages,
|
||||
} = require('~/models');
|
||||
const { bulkIncrementTagCounts, bulkSaveConvos, bulkSaveMessages } = require('~/models');
|
||||
const { FALLBACK_MODEL_BY_ENDPOINT } = require('./defaults');
|
||||
|
||||
/**
|
||||
|
|
@ -55,19 +50,16 @@ 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: false,
|
||||
isTemporary,
|
||||
expiredAt: createTempChatExpirationDate(this.interfaceConfig),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[ImportBatchBuilder] Error creating import expiration date:', error);
|
||||
this.retentionFields = { isTemporary: false, expiredAt: createFallbackRetentionDate() };
|
||||
this.retentionFields = { isTemporary, expiredAt: createFallbackRetentionDate() };
|
||||
}
|
||||
return this.retentionFields;
|
||||
}
|
||||
|
|
@ -160,11 +152,6 @@ 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,7 +5,6 @@ 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,11 +9,7 @@ const {
|
|||
} = require('librechat-data-provider');
|
||||
const { getImporter, processAssistantMessage } = require('./importers');
|
||||
const { ImportBatchBuilder } = require('./importBatchBuilder');
|
||||
const {
|
||||
applyForcedRetention,
|
||||
bulkSaveMessages,
|
||||
bulkSaveConvos: _bulkSaveConvos,
|
||||
} = require('~/models');
|
||||
const { bulkSaveMessages, bulkSaveConvos: _bulkSaveConvos } = require('~/models');
|
||||
|
||||
const mockGetEndpointsConfig = jest.fn().mockResolvedValue({
|
||||
[EModelEndpoint.openAI]: { userProvide: false },
|
||||
|
|
@ -31,7 +27,6 @@ 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(),
|
||||
|
|
@ -1151,7 +1146,7 @@ describe('importLibreChatConvo', () => {
|
|||
expect(result.conversation.expiredAt).toBe(message.expiredAt);
|
||||
});
|
||||
|
||||
it('routes ephemeral imports through the forced-retention chokepoint', async () => {
|
||||
it('marks imported conversations and messages temporary under ephemeral retention', () => {
|
||||
const requestUserId = 'user-123';
|
||||
const builder = new ImportBatchBuilder(requestUserId, {
|
||||
retentionMode: RetentionMode.EPHEMERAL,
|
||||
|
|
@ -1161,17 +1156,11 @@ describe('importLibreChatConvo', () => {
|
|||
const message = builder.addUserMessage('Ephemeral import');
|
||||
const result = builder.finishConversation('Imported ephemeral chat');
|
||||
|
||||
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 },
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,22 +3,18 @@ import { useRecoilValue } from 'recoil';
|
|||
import * as Ariakit from '@ariakit/react';
|
||||
import { BookmarkPlusIcon } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Constants, QueryKeys } from 'librechat-data-provider';
|
||||
import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons';
|
||||
import { Constants, QueryKeys, isForcedTemporaryRetention } from 'librechat-data-provider';
|
||||
import { DropdownPopup, TooltipAnchor, Spinner, useToastContext } from '@librechat/client';
|
||||
import type { TConversationTag } from 'librechat-data-provider';
|
||||
import type { FC } from 'react';
|
||||
import type * as t from '~/common';
|
||||
import {
|
||||
useGetStartupConfig,
|
||||
useConversationTagsQuery,
|
||||
useTagConversationMutation,
|
||||
} from '~/data-provider';
|
||||
import { useConversationTagsQuery, useTagConversationMutation } from '~/data-provider';
|
||||
import { BookmarkContext } from '~/Providers/BookmarkContext';
|
||||
import { cn, isTemporaryConversation, logger } from '~/utils';
|
||||
import { BookmarkEditDialog } from '~/components/Bookmarks';
|
||||
import { useBookmarkSuccess, useLocalize } from '~/hooks';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { cn, isTemporaryConversation, logger } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const BookmarkMenu: FC = () => {
|
||||
|
|
@ -29,16 +25,8 @@ const BookmarkMenu: FC = () => {
|
|||
const conversation = useRecoilValue(store.conversationByIndex(0)) || undefined;
|
||||
const conversationId = conversation?.conversationId ?? '';
|
||||
const updateConvoTags = useBookmarkSuccess(conversationId);
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const tags = conversation?.tags;
|
||||
/**
|
||||
* A pre-existing permanent chat loaded under forced (ephemeral) retention stays
|
||||
* non-temporary until the server converts it on the next write, so gate on the forced flag too
|
||||
* to keep permanent-chat bookmarking hidden on a chat that is already effectively temporary.
|
||||
*/
|
||||
const isTemporary =
|
||||
isTemporaryConversation(conversation) ||
|
||||
isForcedTemporaryRetention(startupConfig?.interface?.retentionMode);
|
||||
const isTemporary = isTemporaryConversation(conversation);
|
||||
const menuId = useId();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
|
|
|||
|
|
@ -234,33 +234,6 @@ 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,7 +29,10 @@ function hasExplicitConfig(
|
|||
case PermissionTypes.AGENTS:
|
||||
return interfaceConfig?.agents !== undefined;
|
||||
case PermissionTypes.TEMPORARY_CHAT:
|
||||
return interfaceConfig?.temporaryChat !== undefined;
|
||||
return (
|
||||
interfaceConfig?.temporaryChat !== undefined ||
|
||||
isForcedTemporaryRetention(interfaceConfig?.retentionMode)
|
||||
);
|
||||
case PermissionTypes.RUN_CODE:
|
||||
return interfaceConfig?.runCode !== undefined;
|
||||
case PermissionTypes.WEB_SEARCH:
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ describe('retention helpers', () => {
|
|||
expect(dependencies.getConvo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns a fresh initial expiry for retentionMode EPHEMERAL', async () => {
|
||||
it('returns expiry when retentionMode is EPHEMERAL', async () => {
|
||||
const result = await getRetentionExpiry(
|
||||
request({ config: { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } } }),
|
||||
dependencies,
|
||||
|
|
@ -304,28 +304,6 @@ describe('retention helpers', () => {
|
|||
expect(dependencies.createExpirationDate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies ephemeral retention to persistent agent files even when retainAgentFiles is enabled', async () => {
|
||||
const result = await getAgentFileRetentionExpiry(
|
||||
{
|
||||
req: request({
|
||||
config: {
|
||||
interfaceConfig: {
|
||||
retentionMode: RetentionMode.EPHEMERAL,
|
||||
retainAgentFiles: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
messageAttachment: false,
|
||||
toolResource: 'context',
|
||||
},
|
||||
dependencies,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ expiredAt: expirationDate });
|
||||
expect(dependencies.getConvo).not.toHaveBeenCalled();
|
||||
expect(dependencies.createExpirationDate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('still applies all-data retention to agent message attachments when retainAgentFiles is enabled', async () => {
|
||||
const result = await getAgentFileRetentionExpiry(
|
||||
{
|
||||
|
|
@ -441,42 +419,5 @@ describe('retention helpers', () => {
|
|||
).resolves.toBe(expiredAt);
|
||||
expect(dependencies.createExpirationDate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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(expirationDate);
|
||||
});
|
||||
|
||||
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(() => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
|
||||
await expect(
|
||||
getSharedLinkExpiration(
|
||||
{
|
||||
req: request({
|
||||
config: { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } },
|
||||
}),
|
||||
conversationId: 'convo-1',
|
||||
},
|
||||
dependencies,
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { isAllDataRetention } from 'librechat-data-provider';
|
||||
import { createFallbackRetentionDate } from '@librechat/data-schemas';
|
||||
import { RetentionMode, isAllDataRetention } from 'librechat-data-provider';
|
||||
import type { AppConfig } from '@librechat/data-schemas';
|
||||
|
||||
type InterfaceConfig = AppConfig['interfaceConfig'];
|
||||
|
|
@ -103,8 +103,7 @@ async function computeRetentionExpiry(
|
|||
req: RetentionRequest | null | undefined,
|
||||
dependencies: RetentionDependencies,
|
||||
): Promise<RetentionExpiry> {
|
||||
const retentionMode = req?.config?.interfaceConfig?.retentionMode;
|
||||
if (isAllDataRetention(retentionMode)) {
|
||||
if (isAllDataRetention(req?.config?.interfaceConfig?.retentionMode)) {
|
||||
return createRetentionExpiry(req, dependencies);
|
||||
}
|
||||
|
||||
|
|
@ -178,11 +177,10 @@ const shouldRetainPersistentAgentFile = ({
|
|||
toolResource,
|
||||
}: AgentFileRetentionRequest): boolean => {
|
||||
const interfaceConfig = req?.config?.interfaceConfig;
|
||||
const retentionMode = interfaceConfig?.retentionMode;
|
||||
return (
|
||||
isPersistentAgentResourceUpload({ messageAttachment, toolResource }) &&
|
||||
(!isAllDataRetention(retentionMode) ||
|
||||
(retentionMode === RetentionMode.ALL && interfaceConfig?.retainAgentFiles === true))
|
||||
(!isAllDataRetention(interfaceConfig?.retentionMode) ||
|
||||
interfaceConfig?.retainAgentFiles === true)
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -204,9 +202,6 @@ export async function getAgentFileRetentionExpiry(
|
|||
* - `undefined`: no decision can be made because the conversation id or row is missing.
|
||||
* - `null`: the share should be stored without an expiration.
|
||||
* - `Date`: the share should expire at that date; callers reject already-expired dates.
|
||||
*
|
||||
* 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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { isValidObjectIdString, logger } from '@librechat/data-schemas';
|
||||
|
||||
import type {
|
||||
AppConfig,
|
||||
ChatProjectMethods,
|
||||
ChatProjectSortBy,
|
||||
ChatProjectSortDirection,
|
||||
|
|
@ -24,7 +23,6 @@ interface ProjectUser {
|
|||
|
||||
interface ProjectRequest extends Request {
|
||||
user?: ProjectUser;
|
||||
config?: AppConfig;
|
||||
}
|
||||
|
||||
type ProjectHandlerDependencies = Pick<
|
||||
|
|
@ -141,7 +139,6 @@ export function createProjectHandlers(deps: ProjectHandlerDependencies): {
|
|||
getUserId(req),
|
||||
conversationId,
|
||||
projectId,
|
||||
req.config?.interfaceConfig,
|
||||
);
|
||||
if (!result) {
|
||||
return res.status(404).json({ error: CONVERSATION_NOT_FOUND });
|
||||
|
|
@ -211,11 +208,7 @@ export function createProjectHandlers(deps: ProjectHandlerDependencies): {
|
|||
}
|
||||
|
||||
try {
|
||||
const result = await deps.deleteChatProject(
|
||||
getUserId(req),
|
||||
projectId,
|
||||
req.config?.interfaceConfig,
|
||||
);
|
||||
const result = await deps.deleteChatProject(getUserId(req), projectId);
|
||||
if (!result.deletedCount) {
|
||||
return res.status(404).json({ error: PROJECT_NOT_FOUND });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,8 @@
|
|||
import mongoose from 'mongoose';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { RetentionMode } from 'librechat-data-provider';
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server';
|
||||
import type { IChatProject, IConversation, IMessage, IMongoFile, ISharedLink } from '~/types';
|
||||
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,
|
||||
retentionMode: RetentionMode.EPHEMERAL,
|
||||
};
|
||||
import type { IChatProject, IConversation } from '~/types';
|
||||
import { createChatProjectMethods, type ChatProjectMethods } from './chatProject';
|
||||
|
||||
jest.mock('~/config/winston', () => ({
|
||||
error: jest.fn(),
|
||||
|
|
@ -27,9 +14,6 @@ jest.mock('~/config/winston', () => ({
|
|||
let mongoServer: InstanceType<typeof MongoMemoryServer>;
|
||||
let ChatProject: mongoose.Model<IChatProject>;
|
||||
let Conversation: mongoose.Model<IConversation>;
|
||||
let Message: mongoose.Model<IMessage>;
|
||||
let SharedLink: mongoose.Model<ISharedLink>;
|
||||
let File: mongoose.Model<IMongoFile>;
|
||||
let methods: ChatProjectMethods;
|
||||
let modelsToCleanup: string[] = [];
|
||||
|
||||
|
|
@ -43,15 +27,7 @@ beforeAll(async () => {
|
|||
|
||||
ChatProject = mongoose.models.ChatProject as mongoose.Model<IChatProject>;
|
||||
Conversation = mongoose.models.Conversation as mongoose.Model<IConversation>;
|
||||
Message = mongoose.models.Message as mongoose.Model<IMessage>;
|
||||
SharedLink = mongoose.models.SharedLink as mongoose.Model<ISharedLink>;
|
||||
File = mongoose.models.File as mongoose.Model<IMongoFile>;
|
||||
const applyForcedRetention = createApplyForcedRetention(mongoose, {
|
||||
logger,
|
||||
refreshProjectStats: (userId, projectId) =>
|
||||
refreshChatProjectStatsForUser(mongoose, userId, projectId),
|
||||
});
|
||||
methods = createChatProjectMethods(mongoose, applyForcedRetention);
|
||||
methods = createChatProjectMethods(mongoose);
|
||||
|
||||
await mongoose.connect(mongoUri);
|
||||
});
|
||||
|
|
@ -70,9 +46,6 @@ afterAll(async () => {
|
|||
afterEach(async () => {
|
||||
await ChatProject.deleteMany({});
|
||||
await Conversation.deleteMany({});
|
||||
await Message.deleteMany({});
|
||||
await SharedLink.deleteMany({});
|
||||
await File.deleteMany({});
|
||||
});
|
||||
|
||||
async function createConversation(user: string, conversationId: string, title: string) {
|
||||
|
|
@ -317,137 +290,4 @@ describe('ChatProject methods', () => {
|
|||
expect(assignment).toBeNull();
|
||||
expect(deleteResult.deletedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('forces ephemeral retention on a permanent chat, its messages, shares, and files when assigning', async () => {
|
||||
const ownerObjectId = new mongoose.Types.ObjectId();
|
||||
const owner = ownerObjectId.toString();
|
||||
const project = await methods.createChatProject(owner, { name: 'Ephemeral' });
|
||||
await createConversation(owner, 'convo-1', 'Permanent');
|
||||
await Message.create([
|
||||
{ messageId: uuidv4(), conversationId: 'convo-1', user: owner, text: 'first' },
|
||||
{ messageId: uuidv4(), conversationId: 'convo-1', user: owner, text: 'second' },
|
||||
]);
|
||||
await SharedLink.create({ conversationId: 'convo-1', user: owner, shareId: uuidv4() });
|
||||
const fileId = uuidv4();
|
||||
await File.collection.insertOne({
|
||||
file_id: fileId,
|
||||
conversationId: 'convo-1',
|
||||
user: ownerObjectId,
|
||||
expiredAt: null,
|
||||
});
|
||||
|
||||
const result = await methods.assignConversationToProject(
|
||||
owner,
|
||||
'convo-1',
|
||||
project._id!.toString(),
|
||||
ephemeralConfig,
|
||||
);
|
||||
|
||||
expect(result?.conversation.isTemporary).toBe(true);
|
||||
expect(result?.conversation.expiredAt).toBeInstanceOf(Date);
|
||||
expect(result?.conversation.chatProjectId).toBe(project._id!.toString());
|
||||
|
||||
const conversation = await Conversation.findOne({
|
||||
user: owner,
|
||||
conversationId: 'convo-1',
|
||||
}).lean<IConversation>();
|
||||
expect(conversation?.isTemporary).toBe(true);
|
||||
expect(conversation?.expiredAt).toBeInstanceOf(Date);
|
||||
|
||||
const messages = await Message.find({
|
||||
user: owner,
|
||||
conversationId: 'convo-1',
|
||||
}).lean<IMessage[]>();
|
||||
expect(messages).toHaveLength(2);
|
||||
for (const message of messages) {
|
||||
expect(message.isTemporary).toBe(true);
|
||||
expect(message.expiredAt).toBeInstanceOf(Date);
|
||||
}
|
||||
|
||||
const share = await SharedLink.findOne({
|
||||
user: owner,
|
||||
conversationId: 'convo-1',
|
||||
}).lean<ISharedLink>();
|
||||
expect(share?.expiredAt).toBeInstanceOf(Date);
|
||||
|
||||
const file = await File.findOne({ file_id: fileId }).lean<IMongoFile>();
|
||||
expect(file?.expiredAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('forces ephemeral retention when removing a chat from its project', async () => {
|
||||
const project = await methods.createChatProject(user, { name: 'Ephemeral' });
|
||||
await createConversation(user, 'convo-1', 'Permanent');
|
||||
await methods.assignConversationToProject(user, 'convo-1', project._id!.toString());
|
||||
|
||||
await methods.assignConversationToProject(user, 'convo-1', null, ephemeralConfig);
|
||||
|
||||
const conversation = await Conversation.findOne({
|
||||
user,
|
||||
conversationId: 'convo-1',
|
||||
}).lean<IConversation>();
|
||||
expect(conversation?.chatProjectId).toBeUndefined();
|
||||
expect(conversation?.isTemporary).toBe(true);
|
||||
expect(conversation?.expiredAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('leaves retention untouched when assigning outside ephemeral mode', async () => {
|
||||
const project = await methods.createChatProject(user, { name: 'Standard' });
|
||||
await createConversation(user, 'convo-1', 'Permanent');
|
||||
|
||||
await methods.assignConversationToProject(user, 'convo-1', project._id!.toString());
|
||||
|
||||
const conversation = await Conversation.findOne({
|
||||
user,
|
||||
conversationId: 'convo-1',
|
||||
}).lean<IConversation>();
|
||||
expect(conversation?.isTemporary ?? null).not.toBe(true);
|
||||
expect(conversation?.expiredAt ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it('forces ephemeral retention on member chats when deleting a project', async () => {
|
||||
const ownerObjectId = new mongoose.Types.ObjectId();
|
||||
const owner = ownerObjectId.toString();
|
||||
const project = await methods.createChatProject(owner, { name: 'Ephemeral' });
|
||||
const projectId = project._id!.toString();
|
||||
await createConversation(owner, 'convo-1', 'First');
|
||||
await createConversation(owner, 'convo-2', 'Second');
|
||||
await methods.assignConversationToProject(owner, 'convo-1', projectId);
|
||||
await methods.assignConversationToProject(owner, 'convo-2', projectId);
|
||||
await Message.create({
|
||||
messageId: uuidv4(),
|
||||
conversationId: 'convo-1',
|
||||
user: owner,
|
||||
text: 'hi',
|
||||
});
|
||||
const fileId = uuidv4();
|
||||
await File.collection.insertOne({
|
||||
file_id: fileId,
|
||||
conversationId: 'convo-1',
|
||||
user: ownerObjectId,
|
||||
expiredAt: null,
|
||||
});
|
||||
|
||||
await methods.deleteChatProject(owner, projectId, ephemeralConfig);
|
||||
|
||||
const conversations = await Conversation.find({
|
||||
user: owner,
|
||||
conversationId: { $in: ['convo-1', 'convo-2'] },
|
||||
}).lean<IConversation[]>();
|
||||
expect(conversations).toHaveLength(2);
|
||||
for (const conversation of conversations) {
|
||||
expect(conversation.chatProjectId).toBeUndefined();
|
||||
expect(conversation.isTemporary).toBe(true);
|
||||
expect(conversation.expiredAt).toBeInstanceOf(Date);
|
||||
}
|
||||
|
||||
const message = await Message.findOne({
|
||||
user: owner,
|
||||
conversationId: 'convo-1',
|
||||
}).lean<IMessage>();
|
||||
expect(message?.isTemporary).toBe(true);
|
||||
expect(message?.expiredAt).toBeInstanceOf(Date);
|
||||
|
||||
const file = await File.findOne({ file_id: fileId }).lean<IMongoFile>();
|
||||
expect(file?.expiredAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import type { FilterQuery, Model, SortOrder, Types } from 'mongoose';
|
||||
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';
|
||||
import { isValidObjectIdString } from '~/utils/objectId';
|
||||
import { buildRetentionVisibilityFilter } from '~/utils/retention';
|
||||
import { escapeRegExp } from '~/utils/string';
|
||||
import type { IChatProject, IChatProjectDocument, IConversation } from '~/types';
|
||||
|
||||
export type ChatProjectSortBy = 'name' | 'createdAt' | 'lastConversationAt';
|
||||
export type ChatProjectSortDirection = 'asc' | 'desc';
|
||||
|
|
@ -52,16 +51,11 @@ export interface ChatProjectMethods {
|
|||
projectId: string,
|
||||
input: UpdateChatProjectInput,
|
||||
): Promise<IChatProject | null>;
|
||||
deleteChatProject(
|
||||
user: string,
|
||||
projectId: string,
|
||||
interfaceConfig?: AppConfig['interfaceConfig'],
|
||||
): Promise<DeleteChatProjectResult>;
|
||||
deleteChatProject(user: string, projectId: string): Promise<DeleteChatProjectResult>;
|
||||
assignConversationToProject(
|
||||
user: string,
|
||||
conversationId: string,
|
||||
projectId: string | null,
|
||||
interfaceConfig?: AppConfig['interfaceConfig'],
|
||||
): Promise<AssignConversationToProjectResult | null>;
|
||||
refreshChatProjectStats(user: string, projectId: string): Promise<IChatProject | null>;
|
||||
}
|
||||
|
|
@ -257,10 +251,7 @@ export async function updateChatProjectLastConversationForUser(
|
|||
await ChatProject.updateOne({ _id: new mongoose.Types.ObjectId(projectId), user }, update);
|
||||
}
|
||||
|
||||
export function createChatProjectMethods(
|
||||
mongoose: typeof import('mongoose'),
|
||||
applyForcedRetention: ApplyForcedRetention,
|
||||
): ChatProjectMethods {
|
||||
export function createChatProjectMethods(mongoose: typeof import('mongoose')): ChatProjectMethods {
|
||||
async function createChatProject(
|
||||
user: string,
|
||||
input: CreateChatProjectInput,
|
||||
|
|
@ -369,7 +360,6 @@ export function createChatProjectMethods(
|
|||
async function deleteChatProject(
|
||||
user: string,
|
||||
projectId: string,
|
||||
interfaceConfig?: AppConfig['interfaceConfig'],
|
||||
): Promise<DeleteChatProjectResult> {
|
||||
if (!isValidObjectIdString(projectId)) {
|
||||
return { deletedCount: 0, modifiedCount: 0 };
|
||||
|
|
@ -383,16 +373,6 @@ export function createChatProjectMethods(
|
|||
return { deletedCount: 0, modifiedCount: 0 };
|
||||
}
|
||||
|
||||
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(
|
||||
{ user, chatProjectId: projectId },
|
||||
|
|
@ -411,7 +391,6 @@ export function createChatProjectMethods(
|
|||
user: string,
|
||||
conversationId: string,
|
||||
projectId: string | null,
|
||||
interfaceConfig?: AppConfig['interfaceConfig'],
|
||||
): Promise<AssignConversationToProjectResult | null> {
|
||||
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
|
||||
const Conversation = mongoose.models.Conversation as Model<IConversation>;
|
||||
|
|
@ -437,16 +416,6 @@ export function createChatProjectMethods(
|
|||
}
|
||||
|
||||
const previousProjectId = conversation.chatProjectId ?? null;
|
||||
|
||||
/**
|
||||
* Convert the touched conversation to the forced (ephemeral) window before capturing the
|
||||
* updated document. The caller returns this object straight to the client, which writes it
|
||||
* into the active conversation and the React Query cache, so it must already carry the
|
||||
* isTemporary/expiredAt fields rather than a stale pre-conversion snapshot. A no-op outside
|
||||
* forced retention.
|
||||
*/
|
||||
await applyForcedRetention(conversationId, user, interfaceConfig);
|
||||
|
||||
const update =
|
||||
normalizedProjectId == null
|
||||
? { $unset: { chatProjectId: '' } }
|
||||
|
|
|
|||
|
|
@ -1,21 +1,11 @@
|
|||
import mongoose from 'mongoose';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server';
|
||||
import { EModelEndpoint, ResourceType, RetentionMode } from 'librechat-data-provider';
|
||||
import type {
|
||||
IAclEntry,
|
||||
IChatProject,
|
||||
IConversation,
|
||||
IMessage,
|
||||
IMongoFile,
|
||||
ISharedLink,
|
||||
} from '../types';
|
||||
import { EModelEndpoint, RetentionMode } from 'librechat-data-provider';
|
||||
import type { IChatProject, IConversation } 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(),
|
||||
|
|
@ -57,16 +47,7 @@ beforeAll(async () => {
|
|||
position: number;
|
||||
}>;
|
||||
|
||||
const applyForcedRetention = createApplyForcedRetention(mongoose, {
|
||||
logger,
|
||||
refreshProjectStats: (userId, projectId) =>
|
||||
refreshChatProjectStatsForUser(mongoose, userId, projectId),
|
||||
});
|
||||
methods = createConversationMethods(
|
||||
mongoose,
|
||||
{ getMessages, deleteMessages },
|
||||
applyForcedRetention,
|
||||
);
|
||||
methods = createConversationMethods(mongoose, { getMessages, deleteMessages });
|
||||
|
||||
await mongoose.connect(mongoUri);
|
||||
});
|
||||
|
|
@ -868,444 +849,6 @@ describe('Conversation Operations', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('forced retention message cascade', () => {
|
||||
const Message = () => mongoose.models.Message as mongoose.Model<IMessage>;
|
||||
const File = () => mongoose.models.File as mongoose.Model<IMongoFile>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await Message().deleteMany({});
|
||||
await File().deleteMany({});
|
||||
});
|
||||
|
||||
it('backfills existing messages when ephemeral converts a permanent conversation', async () => {
|
||||
const conversationId = uuidv4();
|
||||
await Conversation.create({
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
title: 'Existing permanent chat',
|
||||
});
|
||||
await Message().create([
|
||||
{ messageId: uuidv4(), conversationId, user: 'user123', text: 'one' },
|
||||
{ messageId: uuidv4(), conversationId, user: 'user123', text: 'two' },
|
||||
]);
|
||||
|
||||
await saveConvo(
|
||||
{
|
||||
userId: 'user123',
|
||||
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
|
||||
},
|
||||
{ conversationId, isArchived: true },
|
||||
);
|
||||
|
||||
const convo = await Conversation.findOne<IConversation>({ conversationId }).lean();
|
||||
expect(convo?.isTemporary).toBe(true);
|
||||
expect(convo?.expiredAt).toBeInstanceOf(Date);
|
||||
|
||||
const messages = await Message().find({ conversationId }).lean();
|
||||
expect(messages).toHaveLength(2);
|
||||
for (const message of messages) {
|
||||
expect(message.isTemporary).toBe(true);
|
||||
expect(message.expiredAt).toBeInstanceOf(Date);
|
||||
}
|
||||
});
|
||||
|
||||
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: owner,
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
title: 'Existing permanent chat',
|
||||
});
|
||||
await File().collection.insertOne({
|
||||
file_id: fileId,
|
||||
conversationId,
|
||||
user: ownerObjectId,
|
||||
expiredAt: null,
|
||||
});
|
||||
|
||||
await saveConvo(
|
||||
{
|
||||
userId: owner,
|
||||
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
|
||||
},
|
||||
{ conversationId, isArchived: true },
|
||||
);
|
||||
|
||||
const file = await File().findOne({ file_id: fileId }).lean<IMongoFile>();
|
||||
expect(file?.expiredAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('caps lagging children when saving an already-conforming temporary conversation', async () => {
|
||||
const conversationId = uuidv4();
|
||||
const fileId = uuidv4();
|
||||
const parentDeadline = new Date(Date.now() + 60 * 60 * 1000);
|
||||
const SharedLink = mongoose.models.SharedLink as mongoose.Model<ISharedLink>;
|
||||
const AclEntry = mongoose.models.AclEntry as mongoose.Model<IAclEntry>;
|
||||
const ownerObjectId = new mongoose.Types.ObjectId();
|
||||
const owner = ownerObjectId.toString();
|
||||
|
||||
await Conversation.create({
|
||||
conversationId,
|
||||
user: owner,
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
title: 'Conforming temporary chat',
|
||||
isTemporary: true,
|
||||
expiredAt: parentDeadline,
|
||||
});
|
||||
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: ownerObjectId,
|
||||
expiredAt: null,
|
||||
});
|
||||
|
||||
await saveConvo(
|
||||
{
|
||||
userId: owner,
|
||||
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
|
||||
},
|
||||
{ conversationId, isArchived: true },
|
||||
);
|
||||
|
||||
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());
|
||||
|
||||
const convo = await Conversation.findOne<IConversation>({ conversationId }).lean();
|
||||
expect(convo?.expiredAt?.getTime()).toBe(parentDeadline.getTime());
|
||||
});
|
||||
|
||||
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: owner,
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
title: 'Existing permanent chat',
|
||||
});
|
||||
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: owner,
|
||||
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
|
||||
},
|
||||
{ conversationId, isArchived: true },
|
||||
);
|
||||
spy.mockRestore();
|
||||
expect(failed).toEqual({ message: 'Error saving conversation' });
|
||||
|
||||
const convertedParent = await Conversation.findOne<IConversation>({
|
||||
conversationId,
|
||||
}).lean();
|
||||
expect(convertedParent?.isTemporary).toBe(true);
|
||||
expect(convertedParent?.expiredAt).toBeInstanceOf(Date);
|
||||
|
||||
await saveConvo(
|
||||
{
|
||||
userId: owner,
|
||||
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
|
||||
},
|
||||
{ conversationId, isArchived: true },
|
||||
);
|
||||
|
||||
const converted = await Conversation.findOne<IConversation>({ conversationId }).lean();
|
||||
expect(converted?.isTemporary).toBe(true);
|
||||
expect(converted?.expiredAt).toBeInstanceOf(Date);
|
||||
|
||||
const message = await Message().findOne({ conversationId }).lean();
|
||||
expect(message?.isTemporary).toBe(true);
|
||||
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);
|
||||
await Conversation.create({
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
title: 'Retained all-mode chat',
|
||||
isTemporary: false,
|
||||
expiredAt: retainedUntil,
|
||||
});
|
||||
await Message().create({
|
||||
messageId: uuidv4(),
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
text: 'retained',
|
||||
isTemporary: false,
|
||||
expiredAt: retainedUntil,
|
||||
});
|
||||
|
||||
await saveConvo(
|
||||
{
|
||||
userId: 'user123',
|
||||
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
|
||||
},
|
||||
{ conversationId, isArchived: true },
|
||||
);
|
||||
|
||||
const convo = await Conversation.findOne<IConversation>({ conversationId }).lean();
|
||||
expect(convo?.isTemporary).toBe(true);
|
||||
expect(convo?.expiredAt?.getTime()).toBeLessThan(retainedUntil.getTime());
|
||||
|
||||
const messages = await Message().find({ conversationId }).lean();
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].isTemporary).toBe(true);
|
||||
expect(messages[0].expiredAt?.getTime()).toBeLessThan(retainedUntil.getTime());
|
||||
});
|
||||
|
||||
it('preserves an earlier retained expiration when switching to ephemeral', async () => {
|
||||
const SharedLink = mongoose.models.SharedLink as mongoose.Model<ISharedLink>;
|
||||
await SharedLink.deleteMany({});
|
||||
const conversationId = uuidv4();
|
||||
const soonerExpiry = new Date(Date.now() + 60 * 60 * 1000);
|
||||
await Conversation.create({
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
title: 'Sooner retained all-mode chat',
|
||||
isTemporary: false,
|
||||
expiredAt: soonerExpiry,
|
||||
});
|
||||
await Message().create({
|
||||
messageId: uuidv4(),
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
text: 'retained',
|
||||
isTemporary: false,
|
||||
expiredAt: soonerExpiry,
|
||||
});
|
||||
await SharedLink.create({
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
shareId: uuidv4(),
|
||||
expiredAt: soonerExpiry,
|
||||
});
|
||||
|
||||
await saveConvo(
|
||||
{
|
||||
userId: 'user123',
|
||||
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
|
||||
},
|
||||
{ conversationId, isArchived: true },
|
||||
);
|
||||
|
||||
const convo = await Conversation.findOne<IConversation>({ conversationId }).lean();
|
||||
expect(convo?.isTemporary).toBe(true);
|
||||
expect(convo?.expiredAt?.getTime()).toBe(soonerExpiry.getTime());
|
||||
|
||||
const message = await Message().findOne({ conversationId }).lean();
|
||||
expect(message?.isTemporary).toBe(true);
|
||||
expect(message?.expiredAt?.getTime()).toBe(soonerExpiry.getTime());
|
||||
|
||||
const share = await SharedLink.findOne({ conversationId }).lean();
|
||||
expect(share?.expiredAt?.getTime()).toBe(soonerExpiry.getTime());
|
||||
});
|
||||
|
||||
it('re-caps an already temporary conversation and its messages to a shorter window', async () => {
|
||||
const conversationId = uuidv4();
|
||||
const longerExpiry = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000);
|
||||
await Conversation.create({
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
title: 'Already temporary chat',
|
||||
isTemporary: true,
|
||||
expiredAt: longerExpiry,
|
||||
});
|
||||
await Message().create({
|
||||
messageId: uuidv4(),
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
text: 'older temporary message',
|
||||
isTemporary: true,
|
||||
expiredAt: longerExpiry,
|
||||
});
|
||||
|
||||
await saveConvo(
|
||||
{
|
||||
userId: 'user123',
|
||||
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
|
||||
},
|
||||
{ conversationId, isArchived: true },
|
||||
);
|
||||
|
||||
const convo = await Conversation.findOne<IConversation>({ conversationId }).lean();
|
||||
expect(convo?.expiredAt?.getTime()).toBeLessThan(longerExpiry.getTime());
|
||||
|
||||
const messages = await Message().find({ conversationId }).lean();
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].isTemporary).toBe(true);
|
||||
expect(messages[0].expiredAt?.getTime()).toBeLessThan(longerExpiry.getTime());
|
||||
});
|
||||
|
||||
it('heals a permanent message row inside an already-ephemeral conversation', async () => {
|
||||
const conversationId = uuidv4();
|
||||
const ctx = {
|
||||
userId: 'user123',
|
||||
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
|
||||
};
|
||||
await saveConvo(ctx, { conversationId, title: 'Already ephemeral' });
|
||||
const parent = await Conversation.findOne<IConversation>({ conversationId }).lean();
|
||||
expect(parent?.expiredAt).toBeInstanceOf(Date);
|
||||
|
||||
const lateMessage = await Message().create({
|
||||
messageId: uuidv4(),
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
text: 'legacy row without retention fields',
|
||||
});
|
||||
|
||||
await saveConvo(ctx, { conversationId, isArchived: true });
|
||||
|
||||
const reloaded = await Message().findById(lateMessage._id).lean();
|
||||
expect(reloaded?.isTemporary).toBe(true);
|
||||
expect(reloaded?.expiredAt?.getTime()).toBe(parent?.expiredAt?.getTime());
|
||||
});
|
||||
|
||||
it('caps an existing permanent shared link when ephemeral converts a conversation', async () => {
|
||||
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',
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
title: 'Existing permanent chat',
|
||||
});
|
||||
const share = await SharedLink.create({
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
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(
|
||||
{
|
||||
userId: 'user123',
|
||||
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
|
||||
},
|
||||
{ conversationId, isArchived: true },
|
||||
);
|
||||
|
||||
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());
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchConversation', () => {
|
||||
it('should find a conversation by conversationId', async () => {
|
||||
await Conversation.create({
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { RetentionMode } from 'librechat-data-provider';
|
|||
import type { FilterQuery, Model, SortOrder } from 'mongoose';
|
||||
import type { DeleteResult } from 'mongoose';
|
||||
import type { AppConfig, IChatProjectDocument, IConversation } from '~/types';
|
||||
import type { ApplyForcedRetention } from '~/utils/retention';
|
||||
import type { MessageMethods } from './message';
|
||||
import {
|
||||
refreshChatProjectStatsForUser,
|
||||
|
|
@ -71,7 +70,6 @@ export interface ConversationMethods {
|
|||
export function createConversationMethods(
|
||||
mongoose: typeof import('mongoose'),
|
||||
messageMethods?: Pick<MessageMethods, 'getMessages' | 'deleteMessages'>,
|
||||
applyForcedRetention?: ApplyForcedRetention,
|
||||
): ConversationMethods {
|
||||
function getMessageMethods() {
|
||||
if (!messageMethods) {
|
||||
|
|
@ -255,10 +253,14 @@ export function createConversationMethods(
|
|||
}
|
||||
|
||||
if (interfaceConfig?.retentionMode === RetentionMode.EPHEMERAL) {
|
||||
delete update.isTemporary;
|
||||
delete update.expiredAt;
|
||||
delete unsetFields.isTemporary;
|
||||
delete unsetFields.expiredAt;
|
||||
update.isTemporary = true;
|
||||
try {
|
||||
update.expiredAt = createTempChatExpirationDate(interfaceConfig);
|
||||
} catch (err) {
|
||||
logger.error('Error creating temporary chat expiration date:', err);
|
||||
logger.info(`---\`saveConvo\` context: ${metadata?.context}`);
|
||||
update.expiredAt = createFallbackRetentionDate();
|
||||
}
|
||||
} else if (interfaceConfig?.retentionMode === RetentionMode.ALL) {
|
||||
if (typeof isTemporary === 'boolean') {
|
||||
update.isTemporary = isTemporary;
|
||||
|
|
@ -329,18 +331,6 @@ 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 type { TMessage } from 'librechat-data-provider';
|
||||
import type { IMessage } from '..';
|
||||
import { createMessageMethods } from './message';
|
||||
import { createModels } from '~/models';
|
||||
import { createMessageMethods } from './message';
|
||||
import type { IMessage } from '..';
|
||||
|
||||
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, async () => null);
|
||||
const methods = createMessageMethods(mongoose);
|
||||
getMessages = methods.getMessages;
|
||||
bulkSaveMessages = methods.bulkSaveMessages;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import mongoose from 'mongoose';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server';
|
||||
import { EToolResources, FileContext, FileSources } from 'librechat-data-provider';
|
||||
import { EToolResources, FileContext } from 'librechat-data-provider';
|
||||
import { _resetStrictCache } from '~/models/plugins/tenantIsolation';
|
||||
import { runAsSystem, tenantStorage } from '~/config/tenantContext';
|
||||
import { runAsSystem } from '~/config/tenantContext';
|
||||
import { createFileMethods } from './file';
|
||||
import { createModels } from '~/models';
|
||||
|
||||
|
|
@ -63,9 +63,6 @@ describe('File Methods', () => {
|
|||
expect(file).not.toBeNull();
|
||||
expect(file?.file_id).toBe(fileId);
|
||||
expect(file?.filename).toBe('test.txt');
|
||||
expect(file?.object).toBe('file');
|
||||
expect(file?.usage).toBe(0);
|
||||
expect(file?.source).toBe(FileSources.local);
|
||||
expect(file?.expiresAt).toBeDefined();
|
||||
});
|
||||
|
||||
|
|
@ -89,100 +86,6 @@ describe('File Methods', () => {
|
|||
expect(file?.file_id).toBe(fileId);
|
||||
expect(file?.expiresAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('casts string owner ids in atomic pipeline upserts', async () => {
|
||||
const fileId = uuidv4();
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
|
||||
const file = await fileMethods.createFile({
|
||||
file_id: fileId,
|
||||
user: userId.toString() as unknown as mongoose.Types.ObjectId,
|
||||
filename: 'owned.txt',
|
||||
filepath: '/uploads/owned.txt',
|
||||
type: 'text/plain',
|
||||
bytes: 100,
|
||||
});
|
||||
|
||||
expect(file?.user).toEqual(userId);
|
||||
await expect(File.countDocuments({ file_id: fileId, user: userId })).resolves.toBe(1);
|
||||
});
|
||||
|
||||
it('casts caller-supplied timestamps in atomic pipeline upserts', async () => {
|
||||
const createdAt = '2026-07-26T10:15:30.000Z';
|
||||
const file = await fileMethods.createFile({
|
||||
file_id: uuidv4(),
|
||||
user: new mongoose.Types.ObjectId(),
|
||||
filename: 'timestamped.png',
|
||||
filepath: '/uploads/timestamped.png',
|
||||
type: 'image/png',
|
||||
bytes: 100,
|
||||
createdAt: createdAt as unknown as Date,
|
||||
});
|
||||
|
||||
expect(file?.createdAt).toBeInstanceOf(Date);
|
||||
expect(file?.createdAt?.toISOString()).toBe(createdAt);
|
||||
});
|
||||
|
||||
it('rejects cross-tenant mutation fields before atomic pipeline upserts', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
|
||||
await expect(
|
||||
tenantStorage.run({ tenantId: 'tenant-a' }, async () =>
|
||||
fileMethods.createFile({
|
||||
file_id: uuidv4(),
|
||||
user: userId,
|
||||
tenantId: 'tenant-b',
|
||||
filename: 'cross-tenant.txt',
|
||||
filepath: '/uploads/cross-tenant.txt',
|
||||
type: 'text/plain',
|
||||
bytes: 100,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow('Cross-tenant tenantId mutation is not allowed');
|
||||
});
|
||||
|
||||
it('derives matching tenant ids from the tenant-scoped upsert filter', async () => {
|
||||
const fileId = uuidv4();
|
||||
const file = await tenantStorage.run({ tenantId: 'tenant-a' }, async () =>
|
||||
fileMethods.createFile({
|
||||
file_id: fileId,
|
||||
user: new mongoose.Types.ObjectId(),
|
||||
tenantId: 'tenant-a',
|
||||
filename: 'tenant-owned.txt',
|
||||
filepath: '/uploads/tenant-owned.txt',
|
||||
type: 'text/plain',
|
||||
bytes: 100,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(file?.tenantId).toBe('tenant-a');
|
||||
await expect(
|
||||
runAsSystem(() => File.countDocuments({ file_id: fileId, tenantId: 'tenant-a' })),
|
||||
).resolves.toBe(1);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { EToolResources, FileContext, FileSources } from 'librechat-data-provider';
|
||||
import { EToolResources, FileContext } from 'librechat-data-provider';
|
||||
import type { FilterQuery, SortOrder, Model } from 'mongoose';
|
||||
import type { IMongoFile } from '~/types/file';
|
||||
import { getTenantId, SYSTEM_TENANT_ID } from '~/config/tenantContext';
|
||||
import { tenantSafeBulkWrite } from '~/utils/tenantBulkWrite';
|
||||
import logger from '../config/winston';
|
||||
|
||||
|
|
@ -365,45 +364,10 @@ export function createFileMethods(mongoose: typeof import('mongoose')): {
|
|||
delete fileData.expiresAt;
|
||||
}
|
||||
|
||||
const uncastFields = Object.fromEntries(
|
||||
Object.entries(fileData).filter(([, value]) => value !== undefined),
|
||||
);
|
||||
|
||||
const activeTenantId = getTenantId();
|
||||
if (activeTenantId !== SYSTEM_TENANT_ID) {
|
||||
if (
|
||||
activeTenantId &&
|
||||
Object.prototype.hasOwnProperty.call(uncastFields, 'tenantId') &&
|
||||
uncastFields.tenantId !== activeTenantId
|
||||
) {
|
||||
throw new Error('[TenantIsolation] Cross-tenant tenantId mutation is not allowed');
|
||||
}
|
||||
delete uncastFields.tenantId;
|
||||
}
|
||||
|
||||
const { expiredAt, ...definedFields } = File.castObject(uncastFields);
|
||||
const insertDefaults = {
|
||||
object: { $ifNull: ['$object', 'file'] },
|
||||
usage: { $ifNull: ['$usage', 0] },
|
||||
source: { $ifNull: ['$source', FileSources.local] },
|
||||
};
|
||||
let expiryUpdate = {};
|
||||
if (expiredAt instanceof Date) {
|
||||
expiryUpdate = {
|
||||
expiredAt: {
|
||||
$min: [{ $ifNull: ['$expiredAt', expiredAt] }, expiredAt],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return File.findOneAndUpdate(
|
||||
{ file_id: data.file_id },
|
||||
[{ $set: { ...insertDefaults, ...definedFields, ...expiryUpdate } }],
|
||||
{
|
||||
new: true,
|
||||
upsert: true,
|
||||
},
|
||||
).lean<IMongoFile>();
|
||||
return File.findOneAndUpdate({ file_id: data.file_id }, fileData, {
|
||||
new: true,
|
||||
upsert: true,
|
||||
}).lean<IMongoFile>();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -46,14 +46,7 @@ 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,
|
||||
refreshChatProjectStatsForUser,
|
||||
type ChatProjectMethods,
|
||||
} from './chatProject';
|
||||
import type { ApplyForcedRetention } from '~/utils/retention';
|
||||
import { createApplyForcedRetention } from '~/utils/retention';
|
||||
import logger from '~/config/winston';
|
||||
import { createChatProjectMethods, type ChatProjectMethods } from './chatProject';
|
||||
export type {
|
||||
AssignConversationToProjectResult,
|
||||
ChatProjectSortBy,
|
||||
|
|
@ -152,7 +145,8 @@ export type AllMethods = UserMethods &
|
|||
ConversationTagMethods &
|
||||
MessageMethods &
|
||||
ConversationMethods &
|
||||
ChatProjectMethods & { applyForcedRetention: ApplyForcedRetention } & TxMethods &
|
||||
ChatProjectMethods &
|
||||
TxMethods &
|
||||
TransactionMethods &
|
||||
SpendTokensMethods &
|
||||
PromptMethods &
|
||||
|
|
@ -206,21 +200,12 @@ export function createMethods(
|
|||
createStructuredTransaction: transactionMethods.createStructuredTransaction,
|
||||
});
|
||||
|
||||
const applyForcedRetention = createApplyForcedRetention(mongoose, {
|
||||
logger,
|
||||
refreshProjectStats: (userId, projectId) =>
|
||||
refreshChatProjectStatsForUser(mongoose, userId, projectId),
|
||||
});
|
||||
const messageMethods = createMessageMethods(mongoose, applyForcedRetention);
|
||||
const messageMethods = createMessageMethods(mongoose);
|
||||
|
||||
const conversationMethods = createConversationMethods(
|
||||
mongoose,
|
||||
{
|
||||
getMessages: messageMethods.getMessages,
|
||||
deleteMessages: messageMethods.deleteMessages,
|
||||
},
|
||||
applyForcedRetention,
|
||||
);
|
||||
const conversationMethods = createConversationMethods(mongoose, {
|
||||
getMessages: messageMethods.getMessages,
|
||||
deleteMessages: messageMethods.deleteMessages,
|
||||
});
|
||||
|
||||
// ACL entry methods (used internally for removeAllPermissions)
|
||||
const aclEntryMethods = createAclEntryMethods(mongoose);
|
||||
|
|
@ -294,8 +279,7 @@ export function createMethods(
|
|||
...createConversationTagMethods(mongoose),
|
||||
...messageMethods,
|
||||
...conversationMethods,
|
||||
...createChatProjectMethods(mongoose, applyForcedRetention),
|
||||
applyForcedRetention,
|
||||
...createChatProjectMethods(mongoose),
|
||||
/* Tier 3 */
|
||||
...txMethods,
|
||||
...transactionMethods,
|
||||
|
|
|
|||
|
|
@ -2,11 +2,8 @@ import mongoose from 'mongoose';
|
|||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { RetentionMode } from 'librechat-data-provider';
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server';
|
||||
import type { IChatProject, IConversation, IMessage, ISharedLink } from '..';
|
||||
import type { ApplyForcedRetention } from '../utils/retention';
|
||||
import type { IMessage } from '..';
|
||||
import { tenantStorage, runAsSystem } from '~/config/tenantContext';
|
||||
import { createApplyForcedRetention } from '../utils/retention';
|
||||
import { refreshChatProjectStatsForUser } from './chatProject';
|
||||
import { createMessageMethods } from './message';
|
||||
import { createModels } from '../models';
|
||||
import logger from '~/config/winston';
|
||||
|
|
@ -26,7 +23,6 @@ let saveMessage: ReturnType<typeof createMessageMethods>['saveMessage'];
|
|||
let getMessages: ReturnType<typeof createMessageMethods>['getMessages'];
|
||||
let updateMessage: ReturnType<typeof createMessageMethods>['updateMessage'];
|
||||
let updateToolCallResult: ReturnType<typeof createMessageMethods>['updateToolCallResult'];
|
||||
let applyForcedRetention: ApplyForcedRetention;
|
||||
let deleteMessages: ReturnType<typeof createMessageMethods>['deleteMessages'];
|
||||
let bulkSaveMessages: ReturnType<typeof createMessageMethods>['bulkSaveMessages'];
|
||||
let updateMessageText: ReturnType<typeof createMessageMethods>['updateMessageText'];
|
||||
|
|
@ -41,12 +37,7 @@ beforeAll(async () => {
|
|||
Object.assign(mongoose.models, models);
|
||||
Message = mongoose.models.Message;
|
||||
|
||||
applyForcedRetention = createApplyForcedRetention(mongoose, {
|
||||
logger,
|
||||
refreshProjectStats: (userId, projectId) =>
|
||||
refreshChatProjectStatsForUser(mongoose, userId, projectId),
|
||||
});
|
||||
const methods = createMessageMethods(mongoose, applyForcedRetention);
|
||||
const methods = createMessageMethods(mongoose);
|
||||
saveMessage = methods.saveMessage;
|
||||
getMessages = methods.getMessages;
|
||||
updateMessage = methods.updateMessage;
|
||||
|
|
@ -1016,525 +1007,6 @@ describe('Message Operations', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('forced retention conversation cascade', () => {
|
||||
const Conversation = () => mongoose.models.Conversation as mongoose.Model<IConversation>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await Conversation().deleteMany({});
|
||||
});
|
||||
|
||||
it('converts a permanent parent conversation and backfills its messages', async () => {
|
||||
const conversationId = uuidv4();
|
||||
await Conversation().create({
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
endpoint: 'openAI',
|
||||
title: 'Existing permanent chat',
|
||||
});
|
||||
const olderMessage = await saveMessage(
|
||||
{ userId: 'user123' },
|
||||
{ messageId: uuidv4(), conversationId, text: 'older', user: 'user123' },
|
||||
);
|
||||
expect(olderMessage?.expiredAt ?? null).toBeNull();
|
||||
|
||||
await saveMessage(
|
||||
{
|
||||
userId: 'user123',
|
||||
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
|
||||
},
|
||||
{ messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' },
|
||||
);
|
||||
|
||||
const convo = await Conversation().findOne({ conversationId }).lean();
|
||||
expect(convo?.isTemporary).toBe(true);
|
||||
expect(convo?.expiredAt).toBeInstanceOf(Date);
|
||||
|
||||
const messages = await getMessages({ conversationId, user: 'user123' });
|
||||
expect(messages).toHaveLength(2);
|
||||
for (const message of messages) {
|
||||
expect(message.isTemporary).toBe(true);
|
||||
expect(message.expiredAt).toBeInstanceOf(Date);
|
||||
}
|
||||
});
|
||||
|
||||
it('converts an active retained (all-mode) parent when switching to ephemeral', async () => {
|
||||
const conversationId = uuidv4();
|
||||
const retainedUntil = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000);
|
||||
await Conversation().create({
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
endpoint: 'openAI',
|
||||
title: 'Retained all-mode chat',
|
||||
isTemporary: false,
|
||||
expiredAt: retainedUntil,
|
||||
});
|
||||
await Message.create({
|
||||
messageId: uuidv4(),
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
text: 'retained',
|
||||
isTemporary: false,
|
||||
expiredAt: retainedUntil,
|
||||
});
|
||||
|
||||
await saveMessage(
|
||||
{
|
||||
userId: 'user123',
|
||||
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
|
||||
},
|
||||
{ messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' },
|
||||
);
|
||||
|
||||
const convo = await Conversation().findOne({ conversationId }).lean();
|
||||
expect(convo?.isTemporary).toBe(true);
|
||||
expect(convo?.expiredAt?.getTime()).toBeLessThan(retainedUntil.getTime());
|
||||
|
||||
const messages = await getMessages({ conversationId, user: 'user123' });
|
||||
expect(messages).toHaveLength(2);
|
||||
for (const message of messages) {
|
||||
expect(message.isTemporary).toBe(true);
|
||||
expect(message.expiredAt?.getTime()).toBeLessThan(retainedUntil.getTime());
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves a carried-over message that already expires sooner than the forced window', async () => {
|
||||
const conversationId = uuidv4();
|
||||
const soonerExpiry = new Date(Date.now() + 30 * 60 * 1000);
|
||||
await Conversation().create({
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
endpoint: 'openAI',
|
||||
title: 'Permanent chat with a sooner-expiring message',
|
||||
isTemporary: false,
|
||||
});
|
||||
const soonerMessageId = uuidv4();
|
||||
await Message.create({
|
||||
messageId: soonerMessageId,
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
text: 'all-mode message with its own sooner TTL',
|
||||
isTemporary: false,
|
||||
expiredAt: soonerExpiry,
|
||||
});
|
||||
|
||||
await saveMessage(
|
||||
{
|
||||
userId: 'user123',
|
||||
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
|
||||
},
|
||||
{ messageId: uuidv4(), conversationId, text: 'follow-up', user: 'user123' },
|
||||
);
|
||||
|
||||
const sooner = await Message.findOne({ messageId: soonerMessageId }).lean();
|
||||
expect(sooner?.isTemporary).toBe(true);
|
||||
expect(sooner?.expiredAt?.getTime()).toBe(soonerExpiry.getTime());
|
||||
});
|
||||
|
||||
it('re-caps an already temporary parent and its messages to a shorter ephemeral window', async () => {
|
||||
const conversationId = uuidv4();
|
||||
const longerExpiry = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000);
|
||||
await Conversation().create({
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
endpoint: 'openAI',
|
||||
title: 'Already temporary chat',
|
||||
isTemporary: true,
|
||||
expiredAt: longerExpiry,
|
||||
});
|
||||
await Message.create({
|
||||
messageId: uuidv4(),
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
text: 'older temporary message',
|
||||
isTemporary: true,
|
||||
expiredAt: longerExpiry,
|
||||
});
|
||||
|
||||
await saveMessage(
|
||||
{
|
||||
userId: 'user123',
|
||||
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
|
||||
},
|
||||
{ messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' },
|
||||
);
|
||||
|
||||
const convo = await Conversation().findOne({ conversationId }).lean();
|
||||
expect(convo?.expiredAt?.getTime()).toBeLessThan(longerExpiry.getTime());
|
||||
|
||||
const messages = await getMessages({ conversationId, user: 'user123' });
|
||||
expect(messages).toHaveLength(2);
|
||||
for (const message of messages) {
|
||||
expect(message.isTemporary).toBe(true);
|
||||
expect(message.expiredAt?.getTime()).toBeLessThan(longerExpiry.getTime());
|
||||
}
|
||||
});
|
||||
|
||||
it('caps a message-only save to a sooner parent expiry instead of orphaning it', async () => {
|
||||
const conversationId = uuidv4();
|
||||
const parentExpiry = new Date(Date.now() + 60 * 60 * 1000);
|
||||
await Conversation().create({
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
endpoint: 'openAI',
|
||||
title: 'Ephemeral chat expiring soon',
|
||||
isTemporary: true,
|
||||
expiredAt: parentExpiry,
|
||||
});
|
||||
|
||||
const saved = await saveMessage(
|
||||
{
|
||||
userId: 'user123',
|
||||
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
|
||||
},
|
||||
{ messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' },
|
||||
{ context: 'branch' },
|
||||
);
|
||||
|
||||
expect(saved?.expiredAt?.getTime()).toBe(parentExpiry.getTime());
|
||||
|
||||
const convo = await Conversation().findOne({ conversationId }).lean();
|
||||
expect(convo?.expiredAt?.getTime()).toBe(parentExpiry.getTime());
|
||||
expect(saved?.expiredAt?.getTime()).toBeLessThanOrEqual(convo?.expiredAt?.getTime() ?? 0);
|
||||
});
|
||||
|
||||
it('caps a message-only save to an all-mode parent expiring sooner without extending it', async () => {
|
||||
const conversationId = uuidv4();
|
||||
const parentExpiry = new Date(Date.now() + 60 * 60 * 1000);
|
||||
await Conversation().create({
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
endpoint: 'openAI',
|
||||
title: 'All-mode chat expiring soon',
|
||||
isTemporary: false,
|
||||
expiredAt: parentExpiry,
|
||||
});
|
||||
const olderMessageId = uuidv4();
|
||||
await Message.create({
|
||||
messageId: olderMessageId,
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
text: 'older retained message',
|
||||
isTemporary: false,
|
||||
expiredAt: parentExpiry,
|
||||
});
|
||||
|
||||
const saved = await saveMessage(
|
||||
{
|
||||
userId: 'user123',
|
||||
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
|
||||
},
|
||||
{ messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' },
|
||||
{ context: 'branch' },
|
||||
);
|
||||
|
||||
expect(saved?.expiredAt?.getTime()).toBe(parentExpiry.getTime());
|
||||
|
||||
const convo = await Conversation().findOne({ conversationId }).lean();
|
||||
expect(convo?.isTemporary).toBe(true);
|
||||
expect(convo?.expiredAt?.getTime()).toBe(parentExpiry.getTime());
|
||||
|
||||
const older = await Message.findOne({ messageId: olderMessageId }).lean();
|
||||
expect(older?.isTemporary).toBe(true);
|
||||
expect(older?.expiredAt?.getTime()).toBe(parentExpiry.getTime());
|
||||
});
|
||||
|
||||
it('backfills lagging messages when the parent already expires sooner', async () => {
|
||||
const conversationId = uuidv4();
|
||||
const parentExpiry = new Date(Date.now() + 60 * 60 * 1000);
|
||||
await Conversation().create({
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
endpoint: 'openAI',
|
||||
title: 'Ephemeral chat expiring soon',
|
||||
isTemporary: true,
|
||||
expiredAt: parentExpiry,
|
||||
});
|
||||
const laggingMessageId = uuidv4();
|
||||
await Message.create({
|
||||
messageId: laggingMessageId,
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
text: 'older message with no expiry',
|
||||
});
|
||||
|
||||
await saveMessage(
|
||||
{
|
||||
userId: 'user123',
|
||||
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
|
||||
},
|
||||
{ messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' },
|
||||
{ context: 'branch' },
|
||||
);
|
||||
|
||||
const lagging = await Message.findOne({ messageId: laggingMessageId }).lean();
|
||||
expect(lagging?.isTemporary).toBe(true);
|
||||
expect(lagging?.expiredAt?.getTime()).toBe(parentExpiry.getTime());
|
||||
});
|
||||
|
||||
it('caps a normal send to a parent expiring sooner so it cannot outlive the conversation', async () => {
|
||||
const conversationId = uuidv4();
|
||||
const parentExpiry = new Date(Date.now() + 60 * 60 * 1000);
|
||||
await Conversation().create({
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
endpoint: 'openAI',
|
||||
title: 'Ephemeral chat expiring soon',
|
||||
isTemporary: true,
|
||||
expiredAt: parentExpiry,
|
||||
});
|
||||
|
||||
const saved = await saveMessage(
|
||||
{
|
||||
userId: 'user123',
|
||||
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
|
||||
},
|
||||
{ messageId: uuidv4(), conversationId, text: 'follow-up', user: 'user123' },
|
||||
);
|
||||
|
||||
expect(saved?.expiredAt?.getTime()).toBe(parentExpiry.getTime());
|
||||
|
||||
const convo = await Conversation().findOne({ conversationId }).lean();
|
||||
expect(convo?.expiredAt?.getTime()).toBe(parentExpiry.getTime());
|
||||
expect(saved?.expiredAt?.getTime()).toBeLessThanOrEqual(convo?.expiredAt?.getTime() ?? 0);
|
||||
});
|
||||
|
||||
it('does not touch the parent conversation outside forced retention', async () => {
|
||||
const conversationId = uuidv4();
|
||||
await Conversation().create({
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
endpoint: 'openAI',
|
||||
title: 'Existing permanent chat',
|
||||
});
|
||||
|
||||
await saveMessage(
|
||||
{
|
||||
userId: 'user123',
|
||||
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.TEMPORARY },
|
||||
},
|
||||
{ messageId: uuidv4(), conversationId, text: 'edit', user: 'user123' },
|
||||
);
|
||||
|
||||
const convo = await Conversation().findOne({ conversationId }).lean();
|
||||
expect(convo?.expiredAt ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it('caps an existing permanent shared link when converting via a message save', async () => {
|
||||
const SharedLink = mongoose.models.SharedLink as mongoose.Model<ISharedLink>;
|
||||
await SharedLink.deleteMany({});
|
||||
const conversationId = uuidv4();
|
||||
await Conversation().create({
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
endpoint: 'openAI',
|
||||
title: 'Existing permanent chat',
|
||||
});
|
||||
await SharedLink.create({ conversationId, user: 'user123', shareId: uuidv4() });
|
||||
|
||||
await saveMessage(
|
||||
{
|
||||
userId: 'user123',
|
||||
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
|
||||
},
|
||||
{ messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' },
|
||||
{ context: 'branch' },
|
||||
);
|
||||
|
||||
const reloaded = await SharedLink.findOne({ conversationId }).lean();
|
||||
expect(reloaded?.expiredAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyForcedRetention', () => {
|
||||
const Conversation = () => mongoose.models.Conversation as mongoose.Model<IConversation>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await Conversation().deleteMany({});
|
||||
});
|
||||
|
||||
it('converts a permanent message and its conversation when editing under ephemeral mode', async () => {
|
||||
const conversationId = uuidv4();
|
||||
const editedMessageId = uuidv4();
|
||||
await Conversation().create({
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
endpoint: 'openAI',
|
||||
title: 'Existing permanent chat',
|
||||
});
|
||||
await Message.create([
|
||||
{ messageId: editedMessageId, conversationId, user: 'user123', text: 'first' },
|
||||
{ messageId: uuidv4(), conversationId, user: 'user123', text: 'second' },
|
||||
]);
|
||||
|
||||
await applyForcedRetention(conversationId, 'user123', {
|
||||
temporaryChatRetention: 24,
|
||||
retentionMode: RetentionMode.EPHEMERAL,
|
||||
});
|
||||
|
||||
const convo = await Conversation().findOne({ conversationId }).lean();
|
||||
expect(convo?.isTemporary).toBe(true);
|
||||
expect(convo?.expiredAt).toBeInstanceOf(Date);
|
||||
|
||||
const messages = await getMessages({ conversationId, user: 'user123' });
|
||||
expect(messages).toHaveLength(2);
|
||||
for (const message of messages) {
|
||||
expect(message.isTemporary).toBe(true);
|
||||
expect(message.expiredAt).toBeInstanceOf(Date);
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves an edited message expiring sooner than its parent under forced retention', async () => {
|
||||
const conversationId = uuidv4();
|
||||
const editedMessageId = uuidv4();
|
||||
const messageDeadline = new Date(Date.now() + 60 * 60 * 1000);
|
||||
const parentDeadline = new Date(Date.now() + 12 * 60 * 60 * 1000);
|
||||
await Conversation().create({
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
endpoint: 'openAI',
|
||||
title: 'Carried-over chat',
|
||||
isTemporary: false,
|
||||
expiredAt: parentDeadline,
|
||||
});
|
||||
await Message.create({
|
||||
messageId: editedMessageId,
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
text: 'edited',
|
||||
isTemporary: false,
|
||||
expiredAt: messageDeadline,
|
||||
});
|
||||
|
||||
await applyForcedRetention(conversationId, 'user123', {
|
||||
temporaryChatRetention: 24,
|
||||
retentionMode: RetentionMode.EPHEMERAL,
|
||||
});
|
||||
|
||||
const message = await Message.findOne({ messageId: editedMessageId, user: 'user123' }).lean();
|
||||
expect(message?.isTemporary).toBe(true);
|
||||
expect(message?.expiredAt?.getTime()).toBe(messageDeadline.getTime());
|
||||
});
|
||||
|
||||
it('refreshes owning project stats when forced retention hides a project chat', async () => {
|
||||
const ChatProject = mongoose.models.ChatProject as mongoose.Model<IChatProject>;
|
||||
await ChatProject.deleteMany({});
|
||||
const conversationId = uuidv4();
|
||||
const project = await ChatProject.create({
|
||||
name: 'Ephemeral Project',
|
||||
user: 'user123',
|
||||
conversationCount: 1,
|
||||
lastConversationId: conversationId,
|
||||
lastConversationAt: new Date(),
|
||||
});
|
||||
const projectId = project._id!.toString();
|
||||
await Conversation().create({
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
endpoint: 'openAI',
|
||||
title: 'Project chat',
|
||||
isTemporary: false,
|
||||
chatProjectId: projectId,
|
||||
});
|
||||
await Message.create({ messageId: uuidv4(), conversationId, user: 'user123', text: 'hi' });
|
||||
|
||||
await applyForcedRetention(conversationId, 'user123', {
|
||||
temporaryChatRetention: 24,
|
||||
retentionMode: RetentionMode.EPHEMERAL,
|
||||
});
|
||||
|
||||
const refreshed = await ChatProject.findById(projectId).lean<IChatProject>();
|
||||
expect(refreshed?.conversationCount).toBe(0);
|
||||
expect(refreshed?.lastConversationId ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it('refreshes owning project stats when a message-only save converts a project chat', async () => {
|
||||
const ChatProject = mongoose.models.ChatProject as mongoose.Model<IChatProject>;
|
||||
await ChatProject.deleteMany({});
|
||||
const conversationId = uuidv4();
|
||||
const project = await ChatProject.create({
|
||||
name: 'Ephemeral Project',
|
||||
user: 'user123',
|
||||
conversationCount: 1,
|
||||
lastConversationId: conversationId,
|
||||
lastConversationAt: new Date(),
|
||||
});
|
||||
const projectId = project._id!.toString();
|
||||
await Conversation().create({
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
endpoint: 'openAI',
|
||||
title: 'Project chat',
|
||||
isTemporary: false,
|
||||
chatProjectId: projectId,
|
||||
});
|
||||
|
||||
await saveMessage(
|
||||
{
|
||||
userId: 'user123',
|
||||
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
|
||||
},
|
||||
{ messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' },
|
||||
{ context: 'branch' },
|
||||
);
|
||||
|
||||
const refreshed = await ChatProject.findById(projectId).lean<IChatProject>();
|
||||
expect(refreshed?.conversationCount).toBe(0);
|
||||
expect(refreshed?.lastConversationId ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it('converts a permanent conversation and its messages without a messageId (tag write)', async () => {
|
||||
const conversationId = uuidv4();
|
||||
await Conversation().create({
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
endpoint: 'openAI',
|
||||
title: 'Existing permanent chat',
|
||||
});
|
||||
await Message.create([
|
||||
{ messageId: uuidv4(), conversationId, user: 'user123', text: 'first' },
|
||||
{ messageId: uuidv4(), conversationId, user: 'user123', text: 'second' },
|
||||
]);
|
||||
|
||||
await applyForcedRetention(conversationId, 'user123', {
|
||||
temporaryChatRetention: 24,
|
||||
retentionMode: RetentionMode.EPHEMERAL,
|
||||
});
|
||||
|
||||
const convo = await Conversation().findOne({ conversationId }).lean();
|
||||
expect(convo?.isTemporary).toBe(true);
|
||||
expect(convo?.expiredAt).toBeInstanceOf(Date);
|
||||
|
||||
const messages = await getMessages({ conversationId, user: 'user123' });
|
||||
expect(messages).toHaveLength(2);
|
||||
for (const message of messages) {
|
||||
expect(message.isTemporary).toBe(true);
|
||||
expect(message.expiredAt).toBeInstanceOf(Date);
|
||||
}
|
||||
});
|
||||
|
||||
it('is a no-op outside forced retention', async () => {
|
||||
const conversationId = uuidv4();
|
||||
const messageId = uuidv4();
|
||||
await Conversation().create({
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
endpoint: 'openAI',
|
||||
title: 'Existing permanent chat',
|
||||
});
|
||||
await Message.create({ messageId, conversationId, user: 'user123', text: 'first' });
|
||||
|
||||
await applyForcedRetention(conversationId, 'user123', {
|
||||
temporaryChatRetention: 24,
|
||||
retentionMode: RetentionMode.TEMPORARY,
|
||||
});
|
||||
|
||||
const convo = await Conversation().findOne({ conversationId }).lean();
|
||||
expect(convo?.expiredAt ?? null).toBeNull();
|
||||
const message = await Message.findOne({ messageId }).lean();
|
||||
expect(message?.expiredAt ?? null).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message cursor pagination', () => {
|
||||
/**
|
||||
* Helper to create messages with specific timestamps
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { RetentionMode } from 'librechat-data-provider';
|
||||
import type { DeleteResult, FilterQuery, Model } from 'mongoose';
|
||||
import type { ApplyForcedRetention } from '~/utils/retention';
|
||||
import type { AppConfig, IMessage } from '~/types';
|
||||
import { createTempChatExpirationDate } from '~/utils/tempChatRetention';
|
||||
import { createFallbackRetentionDate } from '~/utils/retention';
|
||||
|
|
@ -75,10 +74,7 @@ export interface MessageMethods {
|
|||
deleteMessages(filter: FilterQuery<IMessage>): Promise<DeleteResult>;
|
||||
}
|
||||
|
||||
export function createMessageMethods(
|
||||
mongoose: typeof import('mongoose'),
|
||||
applyForcedRetention: ApplyForcedRetention,
|
||||
): MessageMethods {
|
||||
export function createMessageMethods(mongoose: typeof import('mongoose')): MessageMethods {
|
||||
/**
|
||||
* Saves a message in the database.
|
||||
*/
|
||||
|
|
@ -116,8 +112,14 @@ export function createMessageMethods(
|
|||
};
|
||||
|
||||
if (interfaceConfig?.retentionMode === RetentionMode.EPHEMERAL) {
|
||||
delete update.isTemporary;
|
||||
delete update.expiredAt;
|
||||
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();
|
||||
}
|
||||
} else if (interfaceConfig?.retentionMode === RetentionMode.ALL) {
|
||||
if (typeof isTemporary === 'boolean') {
|
||||
update.isTemporary = isTemporary;
|
||||
|
|
@ -150,7 +152,6 @@ export function createMessageMethods(
|
|||
logger.info(`---\`saveMessage\` context: ${metadata?.context}`);
|
||||
update.tokenCount = 0;
|
||||
}
|
||||
|
||||
const message = await Message.findOneAndUpdate(
|
||||
{ messageId: params.messageId, user: userId },
|
||||
update,
|
||||
|
|
@ -170,17 +171,6 @@ export function createMessageMethods(
|
|||
message.isTemporary = false;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return message.toObject();
|
||||
} catch (err: unknown) {
|
||||
logger.error('Error saving message:', err);
|
||||
|
|
|
|||
|
|
@ -1,38 +1,11 @@
|
|||
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 type { FilterQuery } from 'mongoose';
|
||||
import { DEFAULT_RETENTION_HOURS } from './tempChatRetention';
|
||||
|
||||
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> =>
|
||||
|
|
@ -57,206 +30,3 @@ export const buildRetentionVisibilityFilter = <
|
|||
|
||||
export const createFallbackRetentionDate = (now: number = Date.now()): Date =>
|
||||
new Date(now + DEFAULT_RETENTION_HOURS * 60 * 60 * 1000);
|
||||
|
||||
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(appConfig);
|
||||
} catch (error) {
|
||||
logger.error('Error creating forced-retention expiration date:', error);
|
||||
return createFallbackRetentionDate();
|
||||
}
|
||||
};
|
||||
|
||||
const collectReferencedFileIds = async (
|
||||
Message: Model<IMessage>,
|
||||
userId: string,
|
||||
conversationId: string,
|
||||
parent: Pick<IConversation, 'files' | 'file_ids'>,
|
||||
): Promise<string[]> => {
|
||||
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 ?? []) {
|
||||
if (typeof reference?.file_id === 'string' && reference.file_id.length > 0) {
|
||||
fileIds.add(reference.file_id);
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const message of messages) {
|
||||
addReferences(message.files);
|
||||
addReferences(message.attachments);
|
||||
}
|
||||
return [...fileIds].filter(Boolean);
|
||||
};
|
||||
|
||||
const createOwnedFileScope = (
|
||||
userId: string,
|
||||
conversationId: string,
|
||||
fileIds: string[],
|
||||
): FilterQuery<IMongoFile> | null => {
|
||||
if (!isValidObjectIdString(userId)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
$or: [
|
||||
{ user: userId, conversationId },
|
||||
...(fileIds.length > 0 ? [{ user: userId, file_id: { $in: fileIds } }] : []),
|
||||
],
|
||||
} as FilterQuery<IMongoFile>;
|
||||
};
|
||||
|
||||
const reconcileTagCounts = async (
|
||||
Conversation: Model<IConversation>,
|
||||
userId: string,
|
||||
tags: string[],
|
||||
): Promise<void> => {
|
||||
const uniqueTags = [...new Set(tags.filter(Boolean))];
|
||||
if (uniqueTags.length === 0) {
|
||||
return;
|
||||
}
|
||||
const counts = await Conversation.aggregate<{ _id: string; count: number }>([
|
||||
{
|
||||
$match: {
|
||||
user: userId,
|
||||
tags: { $in: uniqueTags },
|
||||
...buildRetentionVisibilityFilter<IConversation>(),
|
||||
},
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
tags: { $setIntersection: [{ $ifNull: ['$tags', []] }, uniqueTags] },
|
||||
},
|
||||
},
|
||||
{ $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 } },
|
||||
},
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
export function createApplyForcedRetention(
|
||||
mongoose: typeof import('mongoose'),
|
||||
{ logger, refreshProjectStats }: ApplyForcedRetentionDependencies,
|
||||
): ApplyForcedRetention {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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.length === 0 ||
|
||||
typeof userId !== 'string' ||
|
||||
userId.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return applyForcedRetention;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue