refactor: centralize ephemeral retention enforcement

This commit is contained in:
Marco Beretta 2026-07-26 19:18:10 +02:00
parent e10c9ba834
commit 6d3a0b955d
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
31 changed files with 681 additions and 2354 deletions

View file

@ -443,10 +443,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
interfaceConfig: req?.config?.interfaceConfig,
},
partialMessage,
{
context: 'api/server/controllers/agents/request.js - partial response on disconnect',
capExpiryToConversation: true,
},
{ context: 'api/server/controllers/agents/request.js - partial response on disconnect' },
);
logger.debug(

View file

@ -173,7 +173,7 @@ async function abortMessage(req, res) {
interfaceConfig: req?.config?.interfaceConfig,
},
{ ...responseMessage, user: userId },
{ context: 'api/server/middleware/abortMiddleware.js', capExpiryToConversation: true },
{ context: 'api/server/middleware/abortMiddleware.js' },
);
// Get conversation for title

View file

@ -49,10 +49,7 @@ const denyRequest = async (req, res, errorMessage) => {
interfaceConfig: req?.config?.interfaceConfig,
},
{ ...userMessage, user: req.user.id },
{
context: `api/server/middleware/denyRequest.js - ${responseText}`,
capExpiryToConversation: true,
},
{ context: `api/server/middleware/denyRequest.js - ${responseText}` },
);
}

View file

@ -54,10 +54,7 @@ const sendError = async (req, res, options, callback) => {
interfaceConfig: req?.config?.interfaceConfig,
},
{ ...errorMessage, user },
{
context: 'api/server/utils/streamResponse.js - sendError',
capExpiryToConversation: true,
},
{ context: 'api/server/utils/streamResponse.js - sendError' },
);
}

View file

@ -367,10 +367,25 @@ describe('share routes', () => {
.send({ targetMessageId: 'msg-123' });
expect(response.status).toBe(200);
expect(applyForcedRetention).toHaveBeenCalledWith(
{ userId: 'user-123', interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } },
{ conversationId: 'convo-123' },
expect.objectContaining({ context: expect.any(String) }),
expect(applyForcedRetention).toHaveBeenCalledWith('convo-123', 'user-123', {
retentionMode: RetentionMode.EPHEMERAL,
});
expect(applyForcedRetention).toHaveBeenCalledTimes(2);
expect(createSharedLink).toHaveBeenCalledWith(
'user-123',
'convo-123',
'msg-123',
undefined,
true,
);
expect(mockGrantCreationPermissions).toHaveBeenCalledWith(
'link-123',
'user-123',
true,
undefined,
);
expect(applyForcedRetention.mock.invocationCallOrder[1]).toBeGreaterThan(
mockGrantCreationPermissions.mock.invocationCallOrder[0],
);
});
@ -388,11 +403,9 @@ describe('share routes', () => {
* active share on retry) must still convert the touched conversation, otherwise a live
* share could outlast a source chat that never converts.
*/
expect(applyForcedRetention).toHaveBeenCalledWith(
{ userId: 'user-123', interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } },
{ conversationId: 'convo-123' },
expect.objectContaining({ context: expect.any(String) }),
);
expect(applyForcedRetention).toHaveBeenCalledWith('convo-123', 'user-123', {
retentionMode: RetentionMode.EPHEMERAL,
});
expect(applyForcedRetention.mock.invocationCallOrder[0]).toBeLessThan(
createSharedLink.mock.invocationCallOrder[0],
);
@ -407,11 +420,17 @@ describe('share routes', () => {
.patch('/api/share/share-123')
.send({ snapshotFiles: false });
expect(applyForcedRetention).toHaveBeenCalledWith(
{ userId: 'user-123', interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } },
{ conversationId: 'convo-123' },
expect.objectContaining({ context: expect.any(String) }),
expect(applyForcedRetention).toHaveBeenCalledWith('convo-123', 'user-123', {
retentionMode: RetentionMode.EPHEMERAL,
});
expect(updateSharedLink).toHaveBeenCalledWith(
'user-123',
'share-123',
undefined,
undefined,
false,
);
expect(mockUpdateSharedLinkPermissionsExpiration).not.toHaveBeenCalled();
});
it('rejects new shares for expired conversations in all retention mode', async () => {

View file

@ -293,7 +293,6 @@ describe('Agent Abort Endpoint', () => {
}),
expect.objectContaining({
context: 'api/server/routes/agents/index.js - abort endpoint',
capExpiryToConversation: true,
}),
);
});

View file

@ -400,10 +400,7 @@ router.post('/chat/abort', configMiddleware, async (req, res) => {
interfaceConfig: req?.config?.interfaceConfig,
},
responseMessage,
{
context: 'api/server/routes/agents/index.js - abort endpoint',
capExpiryToConversation: true,
},
{ context: 'api/server/routes/agents/index.js - abort endpoint' },
);
logger.debug(`[AgentStream] Saved partial response for: ${jobStreamId}`);
} catch (saveError) {

View file

@ -26,12 +26,8 @@ router.use(requireJwtAuth);
* Enforces forced (ephemeral) retention after a message-only update (edit/feedback),
* which bypasses the saveMessage/saveConvo enforcement. No-op outside forced retention.
*/
const enforceForcedRetention = (req, conversationId, messageId, context) =>
db.applyForcedRetention(
{ userId: req?.user?.id, interfaceConfig: req?.config?.interfaceConfig },
{ conversationId, messageId },
{ context, capExpiryToConversation: true },
);
const enforceForcedRetention = (req, conversationId) =>
db.applyForcedRetention(conversationId, req?.user?.id, req?.config?.interfaceConfig);
router.get('/', async (req, res) => {
try {
@ -193,7 +189,7 @@ router.post('/branch', configMiddleware, async (req, res) => {
interfaceConfig: req?.config?.interfaceConfig,
},
newMessage,
{ context: 'POST /api/messages/branch', capExpiryToConversation: true },
{ context: 'POST /api/messages/branch' },
);
if (!savedMessage) {
@ -274,7 +270,7 @@ router.post('/artifact/:messageId', configMiddleware, async (req, res) => {
content: message.content,
user: req.user.id,
},
{ context: 'POST /api/messages/artifact/:messageId', capExpiryToConversation: true },
{ context: 'POST /api/messages/artifact/:messageId' },
);
res.status(200).json({
@ -387,12 +383,7 @@ router.put(
);
const tokenCount = await countTokens(textToCount, model);
const result = await db.updateMessage(req?.user?.id, { messageId, text, tokenCount });
await enforceForcedRetention(
req,
conversationId,
messageId,
'PUT /api/messages - edit text',
);
await enforceForcedRetention(req, conversationId);
return res.status(200).json(result);
}
@ -437,12 +428,7 @@ router.put(
content: updatedContent,
tokenCount,
});
await enforceForcedRetention(
req,
conversationId,
messageId,
'PUT /api/messages - edit content',
);
await enforceForcedRetention(req, conversationId);
return res.status(200).json(result);
} catch (error) {
logger.error('Error updating message:', error);
@ -468,7 +454,7 @@ router.put(
},
{ context: 'updateFeedback' },
);
await enforceForcedRetention(req, conversationId, messageId, 'PUT /api/messages - feedback');
await enforceForcedRetention(req, conversationId);
// Best-effort: Assistants messages do not have deterministic AgentRun traces.
if (!isAssistantsEndpoint(updatedMessage.endpoint)) {

View file

@ -21,7 +21,12 @@ const {
SYSTEM_TENANT_ID,
createTempChatExpirationDate,
} = require('@librechat/data-schemas');
const { FileSources, PermissionTypes, Permissions } = require('librechat-data-provider');
const {
FileSources,
PermissionTypes,
Permissions,
RetentionMode,
} = require('librechat-data-provider');
const {
getFiles,
updateFile,
@ -74,12 +79,8 @@ const resolveSharedLinkExpiration = (req, conversationId) =>
* retention, so sharing an older permanent chat does not leave it visible and non-expiring
* after the public link itself expires; a no-op outside forced retention.
*/
const enforceForcedRetention = (req, conversationId, context) =>
applyForcedRetention(
{ userId: req?.user?.id, interfaceConfig: req?.config?.interfaceConfig },
{ conversationId },
{ context },
);
const enforceForcedRetention = (req, conversationId) =>
applyForcedRetention(conversationId, req?.user?.id, req?.config?.interfaceConfig);
/**
* Shared messages
@ -474,15 +475,15 @@ router.post(
* cascade again. Converting first also lets the share expiration below read the
* converted conversation's deadline.
*/
await enforceForcedRetention(
req,
req.params.conversationId,
'POST /api/share/:conversationId',
);
await enforceForcedRetention(req, req.params.conversationId);
const expiredAt = await resolveSharedLinkExpiration(req, req.params.conversationId);
if (expiredAt != null && !isActiveExpirationDate(expiredAt)) {
return res.status(404).end();
}
const writeExpiredAt =
req.config?.interfaceConfig?.retentionMode === RetentionMode.EPHEMERAL
? undefined
: expiredAt;
const role = await getRoleByName(req.user.role);
const sharedLinksPerms = role?.permissions?.[PermissionTypes.SHARED_LINKS] || {};
@ -495,11 +496,12 @@ router.post(
req.user.id,
req.params.conversationId,
targetMessageId,
expiredAt,
writeExpiredAt,
snapshotFiles,
);
if (created) {
await grantCreationPermissions(created._id, req.user.id, grantPublic, expiredAt);
await grantCreationPermissions(created._id, req.user.id, grantPublic, writeExpiredAt);
await enforceForcedRetention(req, req.params.conversationId);
res.status(200).json(created);
} else {
res.status(404).end();
@ -530,20 +532,24 @@ router.patch('/:shareId', requireJwtAuth, configMiddleware, async (req, res) =>
if (expiredAt != null && !isActiveExpirationDate(expiredAt)) {
return res.status(404).end();
}
const writeExpiredAt =
req.config?.interfaceConfig?.retentionMode === RetentionMode.EPHEMERAL
? undefined
: expiredAt;
const updatedShare = await updateSharedLink(
req.user.id,
req.params.shareId,
targetMessageId,
expiredAt,
writeExpiredAt,
isFileSnapshotEnabled(req.config) && req.body?.snapshotFiles !== false,
);
if (updatedShare) {
if (updatedShare._id && expiredAt !== undefined) {
await updateSharedLinkPermissionsExpiration(updatedShare._id, expiredAt);
if (updatedShare._id && writeExpiredAt !== undefined) {
await updateSharedLinkPermissionsExpiration(updatedShare._id, writeExpiredAt);
}
if (existing?.conversationId) {
await enforceForcedRetention(req, existing.conversationId, 'PATCH /api/share/:shareId');
await enforceForcedRetention(req, existing.conversationId);
}
res.status(200).json(updatedShare);
} else {

View file

@ -1,3 +1,4 @@
const mongoose = require('mongoose');
const express = require('express');
const { logger } = require('@librechat/data-schemas');
const { generateCheckAccess } = require('@librechat/api');
@ -9,7 +10,6 @@ const {
deleteConversationTag,
getConversationTags,
applyForcedRetention,
applyForcedRetentionToTag,
getRoleByName,
} = require('~/models');
const { requireJwtAuth, configMiddleware } = require('~/server/middleware');
@ -29,24 +29,25 @@ router.use(checkBookmarkAccess);
* Enforces forced (ephemeral) retention after a bookmark-tag write converts an older
* permanent conversation; a no-op outside forced retention.
*/
const enforceForcedRetention = (req, conversationId, context) =>
applyForcedRetention(
{ userId: req?.user?.id, interfaceConfig: req?.config?.interfaceConfig },
{ conversationId },
{ context },
);
const enforceForcedRetention = (req, conversationId) =>
applyForcedRetention(conversationId, req?.user?.id, req?.config?.interfaceConfig);
/**
* Enforces forced (ephemeral) retention on every conversation carrying a tag, for global
* tag renames/deletes that rewrite conversation rows without converting them; a no-op
* outside forced retention.
*/
const enforceForcedRetentionForTag = (req, tag, context) =>
applyForcedRetentionToTag(
{ userId: req?.user?.id, interfaceConfig: req?.config?.interfaceConfig },
{ tag },
{ context },
const enforceForcedRetentionForTag = async (req, tag) => {
const conversations = await mongoose.models.Conversation.find(
{ user: req.user.id, tags: tag },
'conversationId',
).lean();
await Promise.all(
conversations.map(({ conversationId }) =>
applyForcedRetention(conversationId, req.user.id, req?.config?.interfaceConfig),
),
);
};
/**
* GET /
@ -78,7 +79,7 @@ router.post('/', configMiddleware, async (req, res) => {
try {
const tag = await createConversationTag(req.user.id, req.body);
if (req.body?.addToConversation && req.body?.conversationId) {
await enforceForcedRetention(req, req.body.conversationId, 'POST /api/tags');
await enforceForcedRetention(req, req.body.conversationId);
}
res.status(200).json(tag);
} catch (error) {
@ -151,11 +152,7 @@ router.put('/convo/:conversationId', configMiddleware, async (req, res) => {
req.params.conversationId,
req.body.tags,
);
await enforceForcedRetention(
req,
req.params.conversationId,
'PUT /api/tags/convo/:conversationId',
);
await enforceForcedRetention(req, req.params.conversationId);
res.status(200).json(conversationTags);
} catch (error) {
logger.error('Error updating conversation tags', error);

View file

@ -1,6 +1,7 @@
const { Constants, ForkOptions, RetentionMode } = require('librechat-data-provider');
jest.mock('~/models', () => ({
applyForcedRetention: jest.fn().mockResolvedValue(null),
getConvo: jest.fn(),
bulkSaveConvos: jest.fn(),
getMessages: jest.fn(),
@ -37,6 +38,7 @@ const {
cloneMessagesWithTimestamps,
} = require('./fork');
const {
applyForcedRetention,
bulkIncrementTagCounts,
getConvo,
bulkSaveConvos,
@ -116,17 +118,10 @@ describe('forkConversation', () => {
interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL, temporaryChatRetention: 1 },
});
expect(bulkSaveConvos).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ isTemporary: true, expiredAt: expect.any(Date) }),
]),
);
expect(bulkSaveMessages).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ isTemporary: true, expiredAt: expect.any(Date) }),
]),
true,
);
expect(applyForcedRetention).toHaveBeenCalledWith(expect.any(String), 'user1', {
retentionMode: RetentionMode.EPHEMERAL,
temporaryChatRetention: 1,
});
});
test('should fork conversation without branches', async () => {
@ -293,17 +288,10 @@ describe('duplicateConversation', () => {
interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL, temporaryChatRetention: 1 },
});
expect(bulkSaveConvos).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ isTemporary: true, expiredAt: expect.any(Date) }),
]),
);
expect(bulkSaveMessages).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ isTemporary: true, expiredAt: expect.any(Date) }),
]),
true,
);
expect(applyForcedRetention).toHaveBeenCalledWith(expect.any(String), 'user1', {
retentionMode: RetentionMode.EPHEMERAL,
temporaryChatRetention: 1,
});
});
test('should duplicate conversation and increment tag counts', async () => {

View file

@ -7,11 +7,16 @@ const {
const {
EModelEndpoint,
Constants,
RetentionMode,
openAISettings,
isAllDataRetention,
isForcedTemporaryRetention,
} = require('librechat-data-provider');
const { bulkIncrementTagCounts, bulkSaveConvos, bulkSaveMessages } = require('~/models');
const {
applyForcedRetention,
bulkIncrementTagCounts,
bulkSaveConvos,
bulkSaveMessages,
} = require('~/models');
const { FALLBACK_MODEL_BY_ENDPOINT } = require('./defaults');
/**
@ -50,16 +55,19 @@ class ImportBatchBuilder {
this.retentionFields = {};
return this.retentionFields;
}
if (this.interfaceConfig?.retentionMode === RetentionMode.EPHEMERAL) {
this.retentionFields = {};
return this.retentionFields;
}
const isTemporary = isForcedTemporaryRetention(this.interfaceConfig?.retentionMode);
try {
this.retentionFields = {
isTemporary,
isTemporary: false,
expiredAt: createTempChatExpirationDate(this.interfaceConfig),
};
} catch (error) {
logger.error('[ImportBatchBuilder] Error creating import expiration date:', error);
this.retentionFields = { isTemporary, expiredAt: createFallbackRetentionDate() };
this.retentionFields = { isTemporary: false, expiredAt: createFallbackRetentionDate() };
}
return this.retentionFields;
}
@ -152,6 +160,11 @@ class ImportBatchBuilder {
),
);
await Promise.all(promises);
await Promise.all(
this.conversations.map(({ conversationId }) =>
applyForcedRetention(conversationId, this.requestUserId, this.interfaceConfig),
),
);
logger.debug(
`user: ${this.requestUserId} | Added ${this.conversations.length} conversations and ${this.messages.length} messages to the DB.`,
);

View file

@ -5,6 +5,7 @@ const { getImporter } = require('./importers');
// Mock the database methods
jest.mock('~/models', () => ({
applyForcedRetention: jest.fn().mockResolvedValue(null),
bulkSaveConvos: jest.fn(),
bulkSaveMessages: jest.fn(),
bulkIncrementTagCounts: jest.fn(),

View file

@ -9,7 +9,11 @@ const {
} = require('librechat-data-provider');
const { getImporter, processAssistantMessage } = require('./importers');
const { ImportBatchBuilder } = require('./importBatchBuilder');
const { bulkSaveMessages, bulkSaveConvos: _bulkSaveConvos } = require('~/models');
const {
applyForcedRetention,
bulkSaveMessages,
bulkSaveConvos: _bulkSaveConvos,
} = require('~/models');
const mockGetEndpointsConfig = jest.fn().mockResolvedValue({
[EModelEndpoint.openAI]: { userProvide: false },
@ -27,6 +31,7 @@ jest.mock('~/server/controllers/ModelController', () => ({
// Mock the database methods
jest.mock('~/models', () => ({
applyForcedRetention: jest.fn().mockResolvedValue(null),
bulkSaveConvos: jest.fn(),
bulkSaveMessages: jest.fn(),
bulkIncrementTagCounts: jest.fn(),
@ -1146,7 +1151,7 @@ describe('importLibreChatConvo', () => {
expect(result.conversation.expiredAt).toBe(message.expiredAt);
});
it('marks imported conversations and messages temporary under ephemeral retention', () => {
it('routes ephemeral imports through the forced-retention chokepoint', async () => {
const requestUserId = 'user-123';
const builder = new ImportBatchBuilder(requestUserId, {
retentionMode: RetentionMode.EPHEMERAL,
@ -1156,11 +1161,17 @@ describe('importLibreChatConvo', () => {
const message = builder.addUserMessage('Ephemeral import');
const result = builder.finishConversation('Imported ephemeral chat');
expect(message.isTemporary).toBe(true);
expect(message.expiredAt).toBeInstanceOf(Date);
expect(result.conversation.isTemporary).toBe(true);
expect(result.conversation.expiredAt).toBeInstanceOf(Date);
expect(result.conversation.expiredAt).toBe(message.expiredAt);
await builder.saveBatch();
expect(message.isTemporary).toBeUndefined();
expect(message.expiredAt).toBeUndefined();
expect(result.conversation.isTemporary).toBeUndefined();
expect(result.conversation.expiredAt).toBeUndefined();
expect(applyForcedRetention).toHaveBeenCalledWith(
result.conversation.conversationId,
requestUserId,
{ retentionMode: RetentionMode.EPHEMERAL, temporaryChatRetention: 24 },
);
});
});
});

View file

@ -1,240 +0,0 @@
const path = require('path');
const {
logger,
runAsSystem,
tenantStorage,
createTempChatExpirationDate,
forcedRetentionGapFilter,
sweepForcedRetention,
BASE_CONFIG_PRINCIPAL_ID,
} = require('@librechat/data-schemas');
const { RetentionMode } = require('librechat-data-provider');
require('module-alias')({ base: path.resolve(__dirname, '..', 'api') });
const connect = require('./connect');
const { getAppConfig } = require('~/server/services/Config');
const { Conversation, Message, SharedLink, File, Config } = require('~/db/models');
const { refreshChatProjectStats } = require('~/models');
const RETENTION_OVERRIDE_PATHS = ['interface.retentionMode', 'interface.temporaryChatRetention'];
const overridesTouchRetention = (overrides) => {
const interfaceOverrides = overrides?.interface;
if (interfaceOverrides == null || typeof interfaceOverrides !== 'object') {
return false;
}
return 'retentionMode' in interfaceOverrides || 'temporaryChatRetention' in interfaceOverrides;
};
const tombstonesTouchRetention = (tombstones) =>
(tombstones ?? []).some(
(tombstone) => tombstone === 'interface' || RETENTION_OVERRIDE_PATHS.includes(tombstone),
);
/**
* Finds active principal-scoped (role/user/group) config overrides that change retention
* behavior. The sweep evaluates one config per tenant, so a principal whose effective
* retention differs from the tenant default would be skipped or swept with the wrong
* deadline; such tenants are refused instead of migrated incorrectly.
*/
async function findPrincipalRetentionOverrides() {
const configs = await Config.find({
isActive: true,
principalId: { $ne: BASE_CONFIG_PRINCIPAL_ID },
})
.select('principalType principalId overrides tombstones')
.lean();
return configs
.filter(
(config) =>
overridesTouchRetention(config.overrides) || tombstonesTouchRetention(config.tombstones),
)
.map((config) => `${config.principalType}:${config.principalId}`);
}
/**
* Converts one tenant's pre-existing data to the forced (ephemeral) window using that tenant's
* own retention config. Runs inside the caller's tenant context, so every query is scoped to the
* tenant (the untenanted bucket runs in the system context and touches the untenanted rows). A
* tenant whose resolved config is not ephemeral is skipped unless `force` is set.
*/
async function sweepTenant({ tenantId, dryRun, force }) {
const label = tenantId ?? 'default';
const appConfig = await getAppConfig(tenantId ? { tenantId } : undefined);
const interfaceConfig = appConfig?.interfaceConfig;
const retentionMode = interfaceConfig?.retentionMode;
if (retentionMode !== RetentionMode.EPHEMERAL && !force) {
logger.info(
`[tenant ${label}] retentionMode is "${retentionMode ?? 'unset'}", not "ephemeral" — skipping.`,
);
return { tenantId: tenantId ?? null, skipped: true, retentionMode };
}
const principalOverrides = await findPrincipalRetentionOverrides();
if (principalOverrides.length > 0 && !force) {
logger.error(
`[tenant ${label}] ${principalOverrides.length} principal-scoped config override(s) change ` +
`retention behavior (${principalOverrides.join(', ')}). The migration evaluates one ` +
'config per tenant, so it cannot safely convert this tenant; remove the overrides or ' +
'pass --force to sweep with the tenant-level config anyway.',
);
return {
tenantId: tenantId ?? null,
skipped: true,
retentionMode,
reason: 'principal-scoped retention overrides',
principalOverrides,
};
}
const forcedExpiredAt = createTempChatExpirationDate(interfaceConfig);
const nonConforming = await Conversation.countDocuments(
forcedRetentionGapFilter(forcedExpiredAt),
);
logger.info(`[tenant ${label}] Found ${nonConforming} non-conforming conversation(s)`, {
forcedExpiredAt,
});
if (dryRun) {
return { tenantId: tenantId ?? null, dryRun: true, nonConforming, forcedExpiredAt };
}
const { projects, ...counts } = await sweepForcedRetention(
Conversation,
Message,
SharedLink,
File,
forcedExpiredAt,
);
/**
* Converted conversations are hidden from project views (isTemporary: true), so recompute the
* cached stats of every project that owned one; otherwise conversationCount and
* lastConversationId keep pointing at chats the project workspace no longer shows.
*/
let projectsRefreshed = 0;
for (const { user, chatProjectId } of projects) {
try {
await refreshChatProjectStats(user, chatProjectId);
projectsRefreshed += 1;
} catch (error) {
logger.error(`[tenant ${label}] Failed to refresh project ${chatProjectId} stats`, error);
}
}
const result = { ...counts, projectsRefreshed };
logger.info(`[tenant ${label}] completed`, result);
return { tenantId: tenantId ?? null, forcedExpiredAt, ...result };
}
/**
* Backfills forced (ephemeral) retention over conversations that predate the mode.
*
* Convert-on-touch only converts chats that are subsequently written, so enabling ephemeral
* retention on a deployment with existing data leaves untouched permanent chats visible and
* non-expiring. This sweep converts every non-conforming conversation, its messages, its
* shares, and its uploaded files to the forced window (capping rather than extending sooner
* deadlines). It is idempotent and safe to re-run.
*
* Each tenant is converted with its OWN retention config: tenants are enumerated and swept inside
* their tenant context (so queries are scoped to that tenant), and a tenant whose config is not
* ephemeral is skipped. This prevents a system/default config from force-expiring a tenant that
* never enabled ephemeral retention. In a mixed deployment, rows without a tenantId cannot be
* scoped to a tenant config, so they are left untouched and must be converted from a
* single-tenant context.
*/
async function migrateEphemeralRetention({ dryRun = true, force = false } = {}) {
await connect();
return runAsSystem(async () => {
logger.info('Starting Ephemeral Retention Migration', { dryRun, force });
const tenantIds = await Conversation.distinct('tenantId');
const realTenants = tenantIds.filter((tenantId) => tenantId != null && tenantId !== '');
/**
* `distinct` only enumerates stored values, so rows missing the tenantId field entirely
* contribute nothing to it. Count them directly ({ tenantId: null } matches both explicit
* null and missing fields) so pre-tenancy rows in a mixed deployment surface a warning
* instead of being silently skipped.
*/
const untenantedCount = await Conversation.countDocuments({
$or: [{ tenantId: null }, { tenantId: '' }],
});
const hasUntenanted = untenantedCount > 0;
const skippedUntenanted = realTenants.length > 0 && hasUntenanted;
const tenants = realTenants.length > 0 ? realTenants : [undefined];
if (skippedUntenanted) {
logger.warn(
`${untenantedCount} conversation(s) have no tenantId; they cannot be scoped to a tenant ` +
'config and are skipped. Re-run in a single-tenant context to convert them.',
);
}
const results = [];
for (const tenantId of tenants) {
const result = tenantId
? await tenantStorage.run({ tenantId }, async () =>
sweepTenant({ tenantId, dryRun, force }),
)
: await sweepTenant({ tenantId, dryRun, force });
results.push(result);
}
return { dryRun, skippedUntenanted, tenants: results };
});
}
if (require.main === module) {
const dryRun = process.argv.includes('--dry-run');
const force = process.argv.includes('--force');
migrateEphemeralRetention({ dryRun, force })
.then((result) => {
if (result.skippedUntenanted) {
console.log('\nNote: conversations without a tenantId were skipped (see log warning).');
}
if (result.tenants.length > 0 && result.tenants.every((tenant) => tenant.skipped)) {
console.log('\n=== NOTHING TO MIGRATE ===');
for (const tenant of result.tenants) {
const label = tenant.tenantId ?? 'default';
const reason = tenant.reason ?? `retentionMode: ${tenant.retentionMode ?? 'unset'}`;
console.log(`[${label}] skipped (${reason})`);
}
console.log('Enable ephemeral retention, or pass --force to run anyway.');
process.exit(1);
}
if (result.dryRun) {
console.log('\n=== DRY RUN RESULTS ===');
for (const tenant of result.tenants) {
const label = tenant.tenantId ?? 'default';
if (tenant.skipped) {
const reason = tenant.reason ?? `retentionMode: ${tenant.retentionMode ?? 'unset'}`;
console.log(`[${label}] skipped (${reason})`);
continue;
}
const expiry = tenant.forcedExpiredAt;
console.log(
`[${label}] non-conforming conversations: ${tenant.nonConforming} ` +
`(forced expiry: ${expiry?.toISOString?.() ?? expiry})`,
);
}
console.log('\nTo run the actual migration, remove the --dry-run flag.');
} else {
console.log('\n=== MIGRATION RESULTS ===');
console.log(JSON.stringify(result, null, 2));
}
process.exit(0);
})
.catch((error) => {
console.error('Ephemeral retention migration failed:', error);
process.exit(1);
});
}
module.exports = { migrateEphemeralRetention };

View file

@ -109,9 +109,7 @@
"migrate:shared-link-permissions:batch": "node config/migrate-shared-link-permissions.js --batch-size=50",
"migrate:orphaned-agent-files:dry-run": "node config/migrate-orphaned-agent-files.js --dry-run",
"migrate:orphaned-agent-files": "node config/migrate-orphaned-agent-files.js",
"migrate:orphaned-agent-files:batch": "node config/migrate-orphaned-agent-files.js --batch-size=50",
"migrate:ephemeral-retention:dry-run": "node config/migrate-ephemeral-retention.js --dry-run",
"migrate:ephemeral-retention": "node config/migrate-ephemeral-retention.js"
"migrate:orphaned-agent-files:batch": "node config/migrate-orphaned-agent-files.js --batch-size=50"
},
"repository": {
"type": "git",

View file

@ -234,6 +234,33 @@ describe('updateInterfacePermissions - permissions', () => {
);
});
it('preserves customized TEMPORARY_CHAT role permissions during ephemeral mode', async () => {
const config = {
interface: {
retentionMode: RetentionMode.EPHEMERAL,
},
};
const configDefaults = { interface: {} } as TConfigDefaults;
const interfaceConfig = await loadDefaultInterface({ config, configDefaults });
const appConfig = { config, interfaceConfig } as unknown as AppConfig;
mockGetRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: false },
},
});
await updateInterfacePermissions({
appConfig,
getRoleByName: mockGetRoleByName,
updateAccessPermissions: mockUpdateAccessPermissions,
});
expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2);
for (const [, permissionsUpdate] of mockUpdateAccessPermissions.mock.calls) {
expect(permissionsUpdate).not.toHaveProperty(PermissionTypes.TEMPORARY_CHAT);
}
});
it('should call updateAccessPermissions with false when permission types are false', async () => {
const config = {
interface: {

View file

@ -29,10 +29,7 @@ function hasExplicitConfig(
case PermissionTypes.AGENTS:
return interfaceConfig?.agents !== undefined;
case PermissionTypes.TEMPORARY_CHAT:
return (
interfaceConfig?.temporaryChat !== undefined ||
isForcedTemporaryRetention(interfaceConfig?.retentionMode)
);
return interfaceConfig?.temporaryChat !== undefined;
case PermissionTypes.RUN_CODE:
return interfaceConfig?.runCode !== undefined;
case PermissionTypes.WEB_SEARCH:

View file

@ -52,41 +52,14 @@ describe('retention helpers', () => {
expect(dependencies.getConvo).not.toHaveBeenCalled();
});
it('returns a fresh expiry when retentionMode is EPHEMERAL and the conversation has no earlier deadline', async () => {
dependencies.getConvo.mockResolvedValue(null);
const result = await getRetentionExpiry(
request({ config: { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } } }),
dependencies,
);
expect(result).toEqual({ expiredAt: expirationDate });
expect(dependencies.getConvo).toHaveBeenCalledWith('user-1', 'convo-1');
});
it('caps an ephemeral file to a parent conversation that expires sooner', async () => {
const soonerExpiry = new Date('2029-01-01T00:00:00.000Z');
dependencies.getConvo.mockResolvedValue({ expiredAt: soonerExpiry });
const result = await getRetentionExpiry(
request({ config: { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } } }),
dependencies,
);
expect(result).toEqual({ expiredAt: soonerExpiry });
});
it('keeps the fresh window when an ephemeral parent expires later', async () => {
dependencies.getConvo.mockResolvedValue({
expiredAt: new Date('2031-01-01T00:00:00.000Z'),
});
it('returns a fresh initial expiry for retentionMode EPHEMERAL', async () => {
const result = await getRetentionExpiry(
request({ config: { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } } }),
dependencies,
);
expect(result).toEqual({ expiredAt: expirationDate });
expect(dependencies.getConvo).not.toHaveBeenCalled();
});
it('returns a fresh expiry when the conversation has an active expiration', async () => {
@ -349,7 +322,7 @@ describe('retention helpers', () => {
);
expect(result).toEqual({ expiredAt: expirationDate });
expect(dependencies.getConvo).toHaveBeenCalledWith('user-1', 'convo-1');
expect(dependencies.getConvo).not.toHaveBeenCalled();
expect(dependencies.createExpirationDate).toHaveBeenCalledTimes(1);
});
@ -469,27 +442,10 @@ describe('retention helpers', () => {
expect(dependencies.createExpirationDate).not.toHaveBeenCalled();
});
it('caps the share at an active source conversation expiration', async () => {
it('leaves forced-retention parent capping to the chokepoint', async () => {
const conversationExpiredAt = new Date(Date.now() + 60 * 60 * 1000);
dependencies.getConvo.mockResolvedValue({ expiredAt: conversationExpiredAt });
await expect(
getSharedLinkExpiration(
{
req: request({
config: { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } },
}),
conversationId: 'convo-1',
},
dependencies,
),
).resolves.toBe(conversationExpiredAt);
});
it('uses the fresh window when it expires before the source conversation', async () => {
const conversationExpiredAt = new Date('2031-01-01T00:00:00.000Z');
dependencies.getConvo.mockResolvedValue({ expiredAt: conversationExpiredAt });
await expect(
getSharedLinkExpiration(
{
@ -503,7 +459,7 @@ describe('retention helpers', () => {
).resolves.toBe(expirationDate);
});
it('falls back to the active source expiration when creating a window throws', async () => {
it('returns null when creating a share expiration throws', async () => {
const conversationExpiredAt = new Date(Date.now() + 60 * 60 * 1000);
dependencies.getConvo.mockResolvedValue({ expiredAt: conversationExpiredAt });
dependencies.createExpirationDate.mockImplementation(() => {
@ -520,7 +476,7 @@ describe('retention helpers', () => {
},
dependencies,
),
).resolves.toBe(conversationExpiredAt);
).resolves.toBeNull();
});
});
});

View file

@ -99,49 +99,11 @@ const getRetentionCacheKey = (req: RetentionRequest): string =>
String(req.body?.isTemporary ?? ''),
].join('|');
/**
* Resolves a forced (ephemeral) file deadline. The file is always retained, but capped to the
* minimum of a freshly created window and the parent conversation's active expiry, so a new
* attachment cannot linger in files/storage after the conversation and messages are TTL-deleted.
*/
async function computeForcedRetentionExpiry(
req: RetentionRequest | null | undefined,
dependencies: RetentionDependencies,
): Promise<RetentionExpiry> {
const fresh = createRetentionExpiry(req, dependencies);
const conversationId = req?.body?.conversationId;
const userId = req?.user?.id;
if (!conversationId || !userId) {
return fresh;
}
try {
const convo = await dependencies.getConvo(userId, conversationId);
const conversationExpiredAt = getConversationExpirationDate(convo);
if (conversationExpiredAt == null) {
return fresh;
}
if (!isActiveExpirationDate(conversationExpiredAt)) {
return { expiredAt: conversationExpiredAt };
}
if (fresh.expiredAt != null && conversationExpiredAt < fresh.expiredAt) {
return { expiredAt: conversationExpiredAt };
}
return fresh;
} catch (err) {
dependencies.logger?.error('[getRetentionExpiry] Error checking conversation retention:', err);
return fresh;
}
}
async function computeRetentionExpiry(
req: RetentionRequest | null | undefined,
dependencies: RetentionDependencies,
): Promise<RetentionExpiry> {
const retentionMode = req?.config?.interfaceConfig?.retentionMode;
if (retentionMode === RetentionMode.EPHEMERAL) {
return computeForcedRetentionExpiry(req, dependencies);
}
if (isAllDataRetention(retentionMode)) {
return createRetentionExpiry(req, dependencies);
}
@ -243,10 +205,8 @@ export async function getAgentFileRetentionExpiry(
* - `null`: the share should be stored without an expiration.
* - `Date`: the share should expire at that date; callers reject already-expired dates.
*
* A share embeds a snapshot of the source conversation's messages, so it must never
* outlive the conversation it was created from. When the source conversation still has an
* active expiration, the share is capped at the earlier of that deadline and a freshly
* created retention window rather than starting a brand-new window.
* Forced-retention parent/child alignment is deliberately not implemented here:
* `applyForcedRetention` owns that invariant after the share and its ACL entries exist.
*/
export async function getSharedLinkExpiration(
{
@ -279,14 +239,10 @@ export async function getSharedLinkExpiration(
}
try {
const createdExpiration = dependencies.createExpirationDate(req?.config?.interfaceConfig);
if (conversationExpiredAt != null && conversationExpiredAt < createdExpiration) {
return conversationExpiredAt;
}
return createdExpiration;
return dependencies.createExpirationDate(req?.config?.interfaceConfig);
} catch (err) {
dependencies.logger?.error('[getSharedLinkExpiration] Error creating expiration date:', err);
return conversationExpiredAt ?? null;
return null;
}
}

View file

@ -3,8 +3,14 @@ import { v4 as uuidv4 } from 'uuid';
import { RetentionMode } from 'librechat-data-provider';
import { MongoMemoryServer } from 'mongodb-memory-server';
import type { IChatProject, IConversation, IMessage, IMongoFile, ISharedLink } from '~/types';
import { createChatProjectMethods, type ChatProjectMethods } from './chatProject';
import {
createChatProjectMethods,
refreshChatProjectStatsForUser,
type ChatProjectMethods,
} from './chatProject';
import { createApplyForcedRetention } from '~/utils/retention';
import { createModels } from '~/models';
import logger from '~/config/winston';
const ephemeralConfig = {
temporaryChatRetention: 24,
@ -40,7 +46,12 @@ beforeAll(async () => {
Message = mongoose.models.Message as mongoose.Model<IMessage>;
SharedLink = mongoose.models.SharedLink as mongoose.Model<ISharedLink>;
File = mongoose.models.File as mongoose.Model<IMongoFile>;
methods = createChatProjectMethods(mongoose);
const applyForcedRetention = createApplyForcedRetention(mongoose, {
logger,
refreshProjectStats: (userId, projectId) =>
refreshChatProjectStatsForUser(mongoose, userId, projectId),
});
methods = createChatProjectMethods(mongoose, applyForcedRetention);
await mongoose.connect(mongoUri);
});

View file

@ -1,20 +1,7 @@
import { isForcedTemporaryRetention } from 'librechat-data-provider';
import type { FilterQuery, Model, SortOrder, Types } from 'mongoose';
import type {
AppConfig,
IChatProject,
IChatProjectDocument,
IConversation,
IMessage,
IMongoFile,
ISharedLink,
} from '~/types';
import {
buildRetentionVisibilityFilter,
cascadeForcedConversationRetention,
cascadeForcedRetentionByProject,
resolveForcedRetentionDate,
} from '~/utils/retention';
import type { AppConfig, IChatProject, IChatProjectDocument, IConversation } from '~/types';
import type { ApplyForcedRetention } from '~/utils/retention';
import { buildRetentionVisibilityFilter } from '~/utils/retention';
import { isValidObjectIdString } from '~/utils/objectId';
import { escapeRegExp } from '~/utils/string';
import logger from '~/config/winston';
@ -270,59 +257,10 @@ export async function updateChatProjectLastConversationForUser(
await ChatProject.updateOne({ _id: new mongoose.Types.ObjectId(projectId), user }, update);
}
export function createChatProjectMethods(mongoose: typeof import('mongoose')): ChatProjectMethods {
/**
* Converts a project's conversations to the forced (ephemeral) window when the deployment runs
* in ephemeral mode. Assigning, removing, or bulk-unassigning a chat rewrites its row without
* setting `isTemporary`/`expiredAt`, so a permanent chat organized after the install switched
* to ephemeral would otherwise stay visible and never expire. A no-op outside forced retention.
*/
function forceProjectConversationRetention(
user: string,
chatProjectId: string,
interfaceConfig?: AppConfig['interfaceConfig'],
): Promise<void> {
if (!isForcedTemporaryRetention(interfaceConfig?.retentionMode)) {
return Promise.resolve();
}
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const Message = mongoose.models.Message as Model<IMessage>;
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
const File = mongoose.models.File as Model<IMongoFile>;
return cascadeForcedRetentionByProject(
Conversation,
Message,
SharedLink,
File,
user,
chatProjectId,
resolveForcedRetentionDate(interfaceConfig),
);
}
async function forceConversationRetention(
user: string,
conversationId: string,
interfaceConfig?: AppConfig['interfaceConfig'],
): Promise<void> {
if (!isForcedTemporaryRetention(interfaceConfig?.retentionMode)) {
return;
}
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const Message = mongoose.models.Message as Model<IMessage>;
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
const File = mongoose.models.File as Model<IMongoFile>;
await cascadeForcedConversationRetention(
Conversation,
Message,
SharedLink,
File,
user,
conversationId,
resolveForcedRetentionDate(interfaceConfig),
);
}
export function createChatProjectMethods(
mongoose: typeof import('mongoose'),
applyForcedRetention: ApplyForcedRetention,
): ChatProjectMethods {
async function createChatProject(
user: string,
input: CreateChatProjectInput,
@ -445,7 +383,15 @@ export function createChatProjectMethods(mongoose: typeof import('mongoose')): C
return { deletedCount: 0, modifiedCount: 0 };
}
await forceProjectConversationRetention(user, projectId, interfaceConfig);
const conversations = await Conversation.find(
{ user, chatProjectId: projectId },
'conversationId',
).lean<Array<Pick<IConversation, 'conversationId'>>>();
await Promise.all(
conversations.map(({ conversationId }) =>
applyForcedRetention(conversationId, user, interfaceConfig),
),
);
const [conversationResult, deleteResult] = await Promise.all([
Conversation.updateMany(
@ -499,7 +445,7 @@ export function createChatProjectMethods(mongoose: typeof import('mongoose')): C
* isTemporary/expiredAt fields rather than a stale pre-conversion snapshot. A no-op outside
* forced retention.
*/
await forceConversationRetention(user, conversationId, interfaceConfig);
await applyForcedRetention(conversationId, user, interfaceConfig);
const update =
normalizedProjectId == null

View file

@ -1,11 +1,21 @@
import mongoose from 'mongoose';
import { v4 as uuidv4 } from 'uuid';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { EModelEndpoint, RetentionMode } from 'librechat-data-provider';
import type { IChatProject, IConversation, IMessage, IMongoFile, ISharedLink } from '../types';
import { EModelEndpoint, ResourceType, RetentionMode } from 'librechat-data-provider';
import type {
IAclEntry,
IChatProject,
IConversation,
IMessage,
IMongoFile,
ISharedLink,
} from '../types';
import { ConversationMethods, createConversationMethods } from './conversation';
import { tenantStorage, runAsSystem } from '~/config/tenantContext';
import { refreshChatProjectStatsForUser } from './chatProject';
import { createApplyForcedRetention } from '~/utils/retention';
import { createModels } from '../models';
import logger from '~/config/winston';
jest.mock('~/config/winston', () => ({
error: jest.fn(),
@ -47,7 +57,16 @@ beforeAll(async () => {
position: number;
}>;
methods = createConversationMethods(mongoose, { getMessages, deleteMessages });
const applyForcedRetention = createApplyForcedRetention(mongoose, {
logger,
refreshProjectStats: (userId, projectId) =>
refreshChatProjectStatsForUser(mongoose, userId, projectId),
});
methods = createConversationMethods(
mongoose,
{ getMessages, deleteMessages },
applyForcedRetention,
);
await mongoose.connect(mongoUri);
});
@ -894,22 +913,24 @@ describe('Conversation Operations', () => {
it('caps existing files when ephemeral converts a permanent conversation', async () => {
const conversationId = uuidv4();
const fileId = uuidv4();
const ownerObjectId = new mongoose.Types.ObjectId();
const owner = ownerObjectId.toString();
await Conversation.create({
conversationId,
user: 'user123',
user: owner,
endpoint: EModelEndpoint.openAI,
title: 'Existing permanent chat',
});
await File().collection.insertOne({
file_id: fileId,
conversationId,
user: new mongoose.Types.ObjectId(),
user: ownerObjectId,
expiredAt: null,
});
await saveConvo(
{
userId: 'user123',
userId: owner,
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
},
{ conversationId, isArchived: true },
@ -924,33 +945,48 @@ describe('Conversation Operations', () => {
const fileId = uuidv4();
const parentDeadline = new Date(Date.now() + 60 * 60 * 1000);
const SharedLink = mongoose.models.SharedLink as mongoose.Model<ISharedLink>;
const AclEntry = mongoose.models.AclEntry as mongoose.Model<IAclEntry>;
const ownerObjectId = new mongoose.Types.ObjectId();
const owner = ownerObjectId.toString();
await Conversation.create({
conversationId,
user: 'user123',
user: owner,
endpoint: EModelEndpoint.openAI,
title: 'Conforming temporary chat',
isTemporary: true,
expiredAt: parentDeadline,
});
await SharedLink.create({ conversationId, user: 'user123', shareId: uuidv4() });
const share = await SharedLink.create({
conversationId,
user: owner,
shareId: uuidv4(),
expiredAt: parentDeadline,
});
await AclEntry.collection.insertOne({
resourceType: ResourceType.SHARED_LINK,
resourceId: share._id,
expiredAt: null,
});
await File().collection.insertOne({
file_id: fileId,
conversationId,
user: new mongoose.Types.ObjectId(),
user: ownerObjectId,
expiredAt: null,
});
await saveConvo(
{
userId: 'user123',
userId: owner,
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
},
{ conversationId, isArchived: true },
);
const share = await SharedLink.findOne({ conversationId }).lean();
expect(share?.expiredAt?.getTime()).toBe(parentDeadline.getTime());
const reloadedShare = await SharedLink.findOne({ conversationId }).lean();
expect(reloadedShare?.expiredAt?.getTime()).toBe(parentDeadline.getTime());
const aclEntry = await AclEntry.findOne({ resourceId: reloadedShare?._id }).lean();
expect(aclEntry?.expiredAt?.getTime()).toBe(parentDeadline.getTime());
const file = await File().findOne({ file_id: fileId }).lean<IMongoFile>();
expect(file?.expiredAt?.getTime()).toBe(parentDeadline.getTime());
@ -959,22 +995,23 @@ describe('Conversation Operations', () => {
expect(convo?.expiredAt?.getTime()).toBe(parentDeadline.getTime());
});
it('keeps the chat convertible when a child backfill fails during forced conversion', async () => {
it('retries child alignment even when a failed backfill left the parent conforming', async () => {
const conversationId = uuidv4();
const owner = new mongoose.Types.ObjectId().toString();
await Conversation.create({
conversationId,
user: 'user123',
user: owner,
endpoint: EModelEndpoint.openAI,
title: 'Existing permanent chat',
});
await Message().create({ messageId: uuidv4(), conversationId, user: 'user123', text: 'hi' });
await Message().create({ messageId: uuidv4(), conversationId, user: owner, text: 'hi' });
const spy = jest.spyOn(File(), 'updateMany').mockImplementationOnce(() => {
throw new Error('file backfill failed');
});
const failed = await saveConvo(
{
userId: 'user123',
userId: owner,
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
},
{ conversationId, isArchived: true },
@ -982,13 +1019,15 @@ describe('Conversation Operations', () => {
spy.mockRestore();
expect(failed).toEqual({ message: 'Error saving conversation' });
const stillPermanent = await Conversation.findOne<IConversation>({ conversationId }).lean();
expect(stillPermanent?.isTemporary ?? null).not.toBe(true);
expect(stillPermanent?.expiredAt ?? null).toBeNull();
const convertedParent = await Conversation.findOne<IConversation>({
conversationId,
}).lean();
expect(convertedParent?.isTemporary).toBe(true);
expect(convertedParent?.expiredAt).toBeInstanceOf(Date);
await saveConvo(
{
userId: 'user123',
userId: owner,
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
},
{ conversationId, isArchived: true },
@ -1003,6 +1042,74 @@ describe('Conversation Operations', () => {
expect(message?.expiredAt).toBeInstanceOf(Date);
});
it('converges children on the next chokepoint call after a concurrent parent shortening', async () => {
const conversationId = uuidv4();
const ownerObjectId = new mongoose.Types.ObjectId();
const owner = ownerObjectId.toString();
const soonerExpiry = new Date(Date.now() + 30 * 60 * 1000);
const messageId = uuidv4();
const convo = await Conversation.create({
conversationId,
user: owner,
endpoint: EModelEndpoint.openAI,
title: 'Concurrent retention chat',
});
await Message().create({ messageId, conversationId, user: owner, text: 'child' });
const FileModel = File();
const updateMany = FileModel.updateMany.bind(FileModel);
let shortened = false;
const racingUpdateMany = jest.fn(
async (
filter: mongoose.FilterQuery<IMongoFile>,
update: mongoose.UpdateQuery<IMongoFile>,
) => {
if (!shortened) {
shortened = true;
await Conversation.collection.updateOne(
{ _id: convo._id },
{ $set: { isTemporary: true, expiredAt: soonerExpiry } },
);
}
return updateMany(filter, update);
},
);
Object.assign(FileModel, { updateMany: racingUpdateMany });
await (async () => {
try {
return await saveConvo(
{
userId: owner,
interfaceConfig: {
temporaryChatRetention: 24,
retentionMode: RetentionMode.EPHEMERAL,
},
},
{ conversationId, isArchived: true },
);
} finally {
Object.assign(FileModel, { updateMany });
}
})();
const parent = await Conversation.findById(convo._id).lean<IConversation>();
expect(parent?.expiredAt?.getTime()).toBe(soonerExpiry.getTime());
const converged = await saveConvo(
{
userId: owner,
interfaceConfig: {
temporaryChatRetention: 24,
retentionMode: RetentionMode.EPHEMERAL,
},
},
{ conversationId, isArchived: true },
);
expect(converged?.expiredAt?.getTime()).toBe(soonerExpiry.getTime());
const message = await Message().findOne({ messageId }).lean<IMessage>();
expect(message?.expiredAt?.getTime()).toBe(soonerExpiry.getTime());
});
it('converts an active retained (all-mode) conversation when switching to ephemeral', async () => {
const conversationId = uuidv4();
const retainedUntil = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000);
@ -1152,8 +1259,11 @@ describe('Conversation Operations', () => {
it('caps an existing permanent shared link when ephemeral converts a conversation', async () => {
const SharedLink = mongoose.models.SharedLink as mongoose.Model<ISharedLink>;
const AclEntry = mongoose.models.AclEntry as mongoose.Model<IAclEntry>;
await SharedLink.deleteMany({});
await AclEntry.deleteMany({});
const conversationId = uuidv4();
const soonerAclExpiry = new Date(Date.now() + 30 * 60 * 1000);
await Conversation.create({
conversationId,
user: 'user123',
@ -1166,6 +1276,18 @@ describe('Conversation Operations', () => {
shareId: uuidv4(),
});
expect(share.expiredAt ?? null).toBeNull();
await AclEntry.collection.insertMany([
{
resourceType: ResourceType.SHARED_LINK,
resourceId: share._id,
expiredAt: null,
},
{
resourceType: ResourceType.SHARED_LINK,
resourceId: share._id,
expiredAt: soonerAclExpiry,
},
]);
await saveConvo(
{
@ -1177,6 +1299,10 @@ describe('Conversation Operations', () => {
const reloaded = await SharedLink.findOne({ conversationId }).lean();
expect(reloaded?.expiredAt).toBeInstanceOf(Date);
const entries = await AclEntry.find({ resourceId: share._id }).sort({ expiredAt: -1 }).lean();
expect(entries).toHaveLength(2);
expect(entries[0].expiredAt?.getTime()).toBe(reloaded?.expiredAt?.getTime());
expect(entries[1].expiredAt?.getTime()).toBe(soonerAclExpiry.getTime());
});
});

View file

@ -1,30 +1,14 @@
import { RetentionMode, isForcedTemporaryRetention } from 'librechat-data-provider';
import { RetentionMode } from 'librechat-data-provider';
import type { FilterQuery, Model, SortOrder } from 'mongoose';
import type { DeleteResult } from 'mongoose';
import type {
AppConfig,
IChatProjectDocument,
IConversation,
IMessage,
IMongoFile,
ISharedLink,
} from '~/types';
import type { AppConfig, IChatProjectDocument, IConversation } from '~/types';
import type { ApplyForcedRetention } from '~/utils/retention';
import type { MessageMethods } from './message';
import {
buildRetentionVisibilityFilter,
capConversationFiles,
capConversationSharedLinks,
capForcedRetentionExpiry,
collectConversationFileIds,
conversationNeedsForcedRetention,
conversationSeedFileIds,
createFallbackRetentionDate,
forceConversationMessagesTemporary,
} from '~/utils/retention';
import {
refreshChatProjectStatsForUser,
updateChatProjectLastConversationForUser,
} from './chatProject';
import { buildRetentionVisibilityFilter, createFallbackRetentionDate } from '~/utils/retention';
import { createTempChatExpirationDate } from '~/utils/tempChatRetention';
import { tenantSafeBulkWrite } from '~/utils/tenantBulkWrite';
import { isValidObjectIdString } from '~/utils/objectId';
@ -87,6 +71,7 @@ export interface ConversationMethods {
export function createConversationMethods(
mongoose: typeof import('mongoose'),
messageMethods?: Pick<MessageMethods, 'getMessages' | 'deleteMessages'>,
applyForcedRetention?: ApplyForcedRetention,
): ConversationMethods {
function getMessageMethods() {
if (!messageMethods) {
@ -253,30 +238,16 @@ export function createConversationMethods(
}
}
const isForcedRetention = isForcedTemporaryRetention(interfaceConfig?.retentionMode);
const mayChangeProjectMembership =
Object.prototype.hasOwnProperty.call(update, 'chatProjectId') ||
Object.prototype.hasOwnProperty.call(unsetFields, 'chatProjectId');
let previousChatProjectId: string | null = null;
let parentRetention: {
isTemporary?: boolean | null;
expiredAt?: Date | null;
files?: string[];
file_ids?: string[];
} | null = null;
if (mayChangeProjectMembership || isForcedRetention) {
if (mayChangeProjectMembership) {
const existing = await Conversation.findOne(
{ conversationId, user: userId },
'chatProjectId isTemporary expiredAt files file_ids',
).lean<{
chatProjectId?: string | null;
isTemporary?: boolean | null;
expiredAt?: Date | null;
files?: string[];
file_ids?: string[];
} | null>();
'chatProjectId',
).lean<{ chatProjectId?: string | null } | null>();
previousChatProjectId = existing?.chatProjectId ?? null;
parentRetention = existing;
}
if (newConversationId) {
@ -284,20 +255,10 @@ export function createConversationMethods(
}
if (interfaceConfig?.retentionMode === RetentionMode.EPHEMERAL) {
update.isTemporary = true;
try {
update.expiredAt = capForcedRetentionExpiry(
parentRetention?.expiredAt,
createTempChatExpirationDate(interfaceConfig),
);
} catch (err) {
logger.error('Error creating temporary chat expiration date:', err);
logger.info(`---\`saveConvo\` context: ${metadata?.context}`);
update.expiredAt = capForcedRetentionExpiry(
parentRetention?.expiredAt,
createFallbackRetentionDate(),
);
}
delete update.isTemporary;
delete update.expiredAt;
delete unsetFields.isTemporary;
delete unsetFields.expiredAt;
} else if (interfaceConfig?.retentionMode === RetentionMode.ALL) {
if (typeof isTemporary === 'boolean') {
update.isTemporary = isTemporary;
@ -323,37 +284,6 @@ export function createConversationMethods(
update.expiredAt = null;
}
const forcedExpiredAt = update.expiredAt;
/**
* Cap the dependent messages, shares, and files whenever forced retention resolves an
* active deadline for a pre-existing conversation, before converting the parent. Running
* before the findOneAndUpdate keeps a failed child from leaving an already-conforming
* parent behind (a retried save would skip the child rows), and running for conforming
* parents too heals children that lag from before the mode switch or a partial earlier
* backfill each cap is an indexed no-op once the chat's children conform.
*/
if (isForcedRetention && forcedExpiredAt instanceof Date && parentRetention != null) {
const Message = mongoose.models.Message as Model<IMessage>;
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
const File = mongoose.models.File as Model<IMongoFile>;
/**
* Referenced file ids (message-attachment rows carry no conversationId) are collected
* only at conversion time: post-conversion uploads always receive a deadline at upload,
* so the id scan over the chat's messages is not needed on every conforming save.
*/
const fileIds = conversationNeedsForcedRetention(parentRetention, forcedExpiredAt)
? await collectConversationFileIds(
Message,
userId,
[conversationId],
conversationSeedFileIds(parentRetention),
)
: [];
await forceConversationMessagesTemporary(Message, userId, conversationId, forcedExpiredAt);
await capConversationSharedLinks(SharedLink, userId, conversationId, forcedExpiredAt);
await capConversationFiles(File, userId, conversationId, forcedExpiredAt, fileIds);
}
const createdAtOnInsert =
metadata?.createdAtOnInsert instanceof Date &&
!Number.isNaN(metadata.createdAtOnInsert.getTime())
@ -399,6 +329,18 @@ export function createConversationMethods(
return null;
}
if (applyForcedRetention) {
const forcedExpiredAt = await applyForcedRetention(
conversation.conversationId ?? conversationId,
userId,
interfaceConfig,
);
if (forcedExpiredAt instanceof Date) {
conversation.isTemporary = true;
conversation.expiredAt = forcedExpiredAt;
}
}
if (
interfaceConfig?.retentionMode === RetentionMode.ALL &&
typeof isTemporary !== 'boolean' &&

View file

@ -1,10 +1,10 @@
import mongoose from 'mongoose';
import type { TMessage } from 'librechat-data-provider';
import { buildTree } from 'librechat-data-provider';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { createModels } from '~/models';
import { createMessageMethods } from './message';
import type { TMessage } from 'librechat-data-provider';
import type { IMessage } from '..';
import { createMessageMethods } from './message';
import { createModels } from '~/models';
jest.mock('~/config/winston', () => ({
error: jest.fn(),
@ -26,7 +26,7 @@ beforeAll(async () => {
Object.assign(mongoose.models, models);
Message = mongoose.models.Message;
const methods = createMessageMethods(mongoose);
const methods = createMessageMethods(mongoose, async () => null);
getMessages = methods.getMessages;
bulkSaveMessages = methods.bulkSaveMessages;

View file

@ -86,6 +86,29 @@ describe('File Methods', () => {
expect(file?.file_id).toBe(fileId);
expect(file?.expiresAt).toBeUndefined();
});
it('updates expiredAt monotonically with an atomic minimum', async () => {
const fileId = uuidv4();
const userId = new mongoose.Types.ObjectId();
const firstExpiry = new Date('2030-01-01T00:00:00.000Z');
const laterExpiry = new Date('2031-01-01T00:00:00.000Z');
const soonerExpiry = new Date('2029-01-01T00:00:00.000Z');
const data = {
file_id: fileId,
user: userId,
filename: 'retained.txt',
filepath: '/uploads/retained.txt',
type: 'text/plain',
bytes: 100,
};
await fileMethods.createFile({ ...data, expiredAt: firstExpiry }, true);
const notExtended = await fileMethods.createFile({ ...data, expiredAt: laterExpiry }, true);
const shortened = await fileMethods.createFile({ ...data, expiredAt: soonerExpiry }, true);
expect(notExtended?.expiredAt?.getTime()).toBe(firstExpiry.getTime());
expect(shortened?.expiredAt?.getTime()).toBe(soonerExpiry.getTime());
});
});
describe('claimCodeFile', () => {

View file

@ -364,10 +364,27 @@ export function createFileMethods(mongoose: typeof import('mongoose')): {
delete fileData.expiresAt;
}
return File.findOneAndUpdate({ file_id: data.file_id }, fileData, {
new: true,
upsert: true,
}).lean<IMongoFile>();
const { expiredAt, ...fields } = fileData;
const definedFields = Object.fromEntries(
Object.entries(fields).filter(([, value]) => value !== undefined),
);
let expiryUpdate = {};
if (expiredAt instanceof Date) {
expiryUpdate = {
expiredAt: {
$min: [{ $ifNull: ['$expiredAt', expiredAt] }, expiredAt],
},
};
}
return File.findOneAndUpdate(
{ file_id: data.file_id },
[{ $set: { ...definedFields, ...expiryUpdate } }],
{
new: true,
upsert: true,
},
).lean<IMongoFile>();
}
/**

View file

@ -46,7 +46,14 @@ import { createPresetMethods, type PresetMethods } from './preset';
import { createConversationTagMethods, type ConversationTagMethods } from './conversationTag';
import { createMessageMethods, type MessageMethods } from './message';
import { createConversationMethods, type ConversationMethods } from './conversation';
import { createChatProjectMethods, type ChatProjectMethods } from './chatProject';
import {
createChatProjectMethods,
refreshChatProjectStatsForUser,
type ChatProjectMethods,
} from './chatProject';
import type { ApplyForcedRetention } from '~/utils/retention';
import { createApplyForcedRetention } from '~/utils/retention';
import logger from '~/config/winston';
export type {
AssignConversationToProjectResult,
ChatProjectSortBy,
@ -145,8 +152,7 @@ export type AllMethods = UserMethods &
ConversationTagMethods &
MessageMethods &
ConversationMethods &
ChatProjectMethods &
TxMethods &
ChatProjectMethods & { applyForcedRetention: ApplyForcedRetention } & TxMethods &
TransactionMethods &
SpendTokensMethods &
PromptMethods &
@ -200,12 +206,21 @@ export function createMethods(
createStructuredTransaction: transactionMethods.createStructuredTransaction,
});
const messageMethods = createMessageMethods(mongoose);
const conversationMethods = createConversationMethods(mongoose, {
getMessages: messageMethods.getMessages,
deleteMessages: messageMethods.deleteMessages,
const applyForcedRetention = createApplyForcedRetention(mongoose, {
logger,
refreshProjectStats: (userId, projectId) =>
refreshChatProjectStatsForUser(mongoose, userId, projectId),
});
const messageMethods = createMessageMethods(mongoose, applyForcedRetention);
const conversationMethods = createConversationMethods(
mongoose,
{
getMessages: messageMethods.getMessages,
deleteMessages: messageMethods.deleteMessages,
},
applyForcedRetention,
);
// ACL entry methods (used internally for removeAllPermissions)
const aclEntryMethods = createAclEntryMethods(mongoose);
@ -279,7 +294,8 @@ export function createMethods(
...createConversationTagMethods(mongoose),
...messageMethods,
...conversationMethods,
...createChatProjectMethods(mongoose),
...createChatProjectMethods(mongoose, applyForcedRetention),
applyForcedRetention,
/* Tier 3 */
...txMethods,
...transactionMethods,

File diff suppressed because it is too large Load diff

View file

@ -1,15 +1,9 @@
import { RetentionMode, isForcedTemporaryRetention } from 'librechat-data-provider';
import { RetentionMode } from 'librechat-data-provider';
import type { DeleteResult, FilterQuery, Model } from 'mongoose';
import type { AppConfig, IConversation, IMessage, IMongoFile, ISharedLink } from '~/types';
import {
capForcedRetentionExpiry,
capForcedRetentionToParent,
cascadeForcedConversationRetention,
cascadeForcedRetentionByTag,
createFallbackRetentionDate,
} from '~/utils/retention';
import type { ApplyForcedRetention } from '~/utils/retention';
import type { AppConfig, IMessage } from '~/types';
import { createTempChatExpirationDate } from '~/utils/tempChatRetention';
import { refreshChatProjectStatsForUser } from './chatProject';
import { createFallbackRetentionDate } from '~/utils/retention';
import { tenantSafeBulkWrite } from '~/utils/tenantBulkWrite';
import logger from '~/config/winston';
@ -25,7 +19,7 @@ export interface MessageMethods {
saveMessage(
ctx: { userId: string; isTemporary?: boolean; interfaceConfig?: AppConfig['interfaceConfig'] },
params: Partial<IMessage> & { newMessageId?: string },
metadata?: { context?: string; capExpiryToConversation?: boolean },
metadata?: { context?: string },
): Promise<IMessage | null | undefined>;
bulkSaveMessages(
messages: Array<Partial<IMessage>>,
@ -54,16 +48,6 @@ export interface MessageMethods {
message: Partial<IMessage> & { newMessageId?: string },
metadata?: { context?: string },
): Promise<Partial<IMessage>>;
applyForcedRetention(
ctx: { userId: string; interfaceConfig?: AppConfig['interfaceConfig'] },
params: { conversationId: string; messageId?: string },
metadata?: { context?: string; capExpiryToConversation?: boolean },
): Promise<void>;
applyForcedRetentionToTag(
ctx: { userId: string; interfaceConfig?: AppConfig['interfaceConfig'] },
params: { tag: string },
metadata?: { context?: string },
): Promise<void>;
deleteMessagesSince(
userId: string,
params: { messageId: string; conversationId: string },
@ -91,7 +75,10 @@ export interface MessageMethods {
deleteMessages(filter: FilterQuery<IMessage>): Promise<DeleteResult>;
}
export function createMessageMethods(mongoose: typeof import('mongoose')): MessageMethods {
export function createMessageMethods(
mongoose: typeof import('mongoose'),
applyForcedRetention: ApplyForcedRetention,
): MessageMethods {
/**
* Saves a message in the database.
*/
@ -106,7 +93,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
interfaceConfig?: AppConfig['interfaceConfig'];
},
params: Partial<IMessage> & { newMessageId?: string },
metadata?: { context?: string; capExpiryToConversation?: boolean },
metadata?: { context?: string },
) {
if (!userId) {
throw new Error('User not authenticated');
@ -129,14 +116,8 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
};
if (interfaceConfig?.retentionMode === RetentionMode.EPHEMERAL) {
update.isTemporary = true;
try {
update.expiredAt = createTempChatExpirationDate(interfaceConfig);
} catch (err) {
logger.error('Error creating temporary chat expiration date:', err);
logger.info(`---\`saveMessage\` context: ${metadata?.context}`);
update.expiredAt = createFallbackRetentionDate();
}
delete update.isTemporary;
delete update.expiredAt;
} else if (interfaceConfig?.retentionMode === RetentionMode.ALL) {
if (typeof isTemporary === 'boolean') {
update.isTemporary = isTemporary;
@ -170,36 +151,6 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
update.tokenCount = 0;
}
const forcedExpiredAt = update.expiredAt;
const isForcedRetention = isForcedTemporaryRetention(interfaceConfig?.retentionMode);
/**
* Under forced retention the new message must never outlive a parent that already
* expires sooner, since `saveConvo` preserves that earlier deadline rather than
* refreshing it. Message-only saves (branch/artifact/abort) additionally backfill the
* parent's other messages and shares because no `saveConvo` follows to cascade; normal
* saves only cap the message itself and let the conversation cascade handle the rest.
*/
if (isForcedRetention && forcedExpiredAt instanceof Date) {
const Conversation = mongoose.models.Conversation as Model<IConversation>;
if (metadata?.capExpiryToConversation === true) {
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
update.expiredAt = await capForcedRetentionToParent(
Conversation,
Message,
SharedLink,
userId,
conversationId,
forcedExpiredAt,
);
} else {
const parent = await Conversation.findOne(
{ conversationId, user: userId },
'expiredAt',
).lean<{ expiredAt?: Date | null } | null>();
update.expiredAt = capForcedRetentionExpiry(parent?.expiredAt, forcedExpiredAt);
}
}
const message = await Message.findOneAndUpdate(
{ messageId: params.messageId, user: userId },
update,
@ -219,29 +170,14 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
message.isTemporary = false;
}
const cascadeExpiredAt = update.expiredAt;
if (isForcedRetention && cascadeExpiredAt instanceof Date) {
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
const File = mongoose.models.File as Model<IMongoFile>;
const convertedParent = await cascadeForcedConversationRetention(
Conversation,
Message,
SharedLink,
File,
userId,
conversationId,
cascadeExpiredAt,
);
/**
* Message-only writes (branch/artifact/abort saves) have no `saveConvo` afterward to
* recompute project stats, so when the cascade just hid a project chat the cached
* count/lastConversationId must be refreshed here.
*/
if (convertedParent) {
await refreshForcedRetentionProjectStats(userId, {
conversationId,
} as FilterQuery<IConversation>);
const forcedExpiredAt = await applyForcedRetention(conversationId, userId, interfaceConfig);
if (forcedExpiredAt instanceof Date) {
message.isTemporary = true;
if (
!(message.expiredAt instanceof Date) ||
message.expiredAt.getTime() > forcedExpiredAt.getTime()
) {
message.expiredAt = forcedExpiredAt;
}
}
@ -549,159 +485,6 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
}
}
/**
* Recomputes the owning project's cached stats after forced retention hides project chats.
* A conversion flips the conversation to `isTemporary: true`, which
* `visibleProjectConversationFilter` excludes, so a stale count/`lastConversationId` would keep
* pointing at a chat the project workspace no longer shows matching `saveConvo`, which already
* refreshes project stats on a retention-visibility change. Scoped to conversations carrying a
* `chatProjectId`; a no-op when none of the touched chats belong to a project.
*/
async function refreshForcedRetentionProjectStats(
userId: string,
conversationFilter: FilterQuery<IConversation>,
): Promise<void> {
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const projectChats = await Conversation.find(
{ user: userId, ...conversationFilter, chatProjectId: { $exists: true, $ne: null } },
'chatProjectId',
).lean<Array<{ chatProjectId?: string | null }>>();
if (projectChats.length === 0) {
return;
}
const projectIds = new Set<string>();
for (const chat of projectChats) {
if (typeof chat.chatProjectId === 'string' && chat.chatProjectId.length > 0) {
projectIds.add(chat.chatProjectId);
}
}
for (const projectId of projectIds) {
await refreshChatProjectStatsForUser(mongoose, userId, projectId);
}
}
/**
* Enforces forced (ephemeral) retention on a conversation (and optionally a specific
* message) that was touched outside `saveMessage`/`saveConvo` message edits, feedback,
* or bookmark-tag writes. Without these, an older permanent chat touched after an install
* switches to ephemeral would stay visible and never expire. Omit `messageId` for
* conversation-only writes (e.g. tag changes) to run just the conversation cascade.
*/
async function applyForcedRetention(
{ userId, interfaceConfig }: { userId: string; interfaceConfig?: AppConfig['interfaceConfig'] },
{ conversationId, messageId }: { conversationId: string; messageId?: string },
metadata?: { context?: string; capExpiryToConversation?: boolean },
): Promise<void> {
if (!isForcedTemporaryRetention(interfaceConfig?.retentionMode)) {
return;
}
if (typeof conversationId !== 'string' || conversationId.length === 0) {
logger.warn(
`[applyForcedRetention] Ignoring non-string conversationId (context: ${metadata?.context ?? 'n/a'})`,
);
return;
}
let forcedExpiredAt: Date;
try {
forcedExpiredAt = createTempChatExpirationDate(interfaceConfig);
} catch (err) {
logger.error('Error creating temporary chat expiration date:', err);
logger.info(`---\`applyForcedRetention\` context: ${metadata?.context}`);
forcedExpiredAt = createFallbackRetentionDate();
}
const Message = mongoose.models.Message as Model<IMessage>;
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
const File = mongoose.models.File as Model<IMongoFile>;
if (metadata?.capExpiryToConversation === true) {
forcedExpiredAt = await capForcedRetentionToParent(
Conversation,
Message,
SharedLink,
userId,
conversationId,
forcedExpiredAt,
);
}
if (typeof messageId === 'string' && messageId.length > 0) {
await Message.updateOne({ messageId, user: userId }, [
{
$set: {
isTemporary: true,
expiredAt: { $min: [{ $ifNull: ['$expiredAt', forcedExpiredAt] }, forcedExpiredAt] },
},
},
]);
}
const convertedParent = await cascadeForcedConversationRetention(
Conversation,
Message,
SharedLink,
File,
userId,
conversationId,
forcedExpiredAt,
);
if (convertedParent) {
await refreshForcedRetentionProjectStats(userId, {
conversationId,
} as FilterQuery<IConversation>);
}
}
/**
* Enforces forced (ephemeral) retention on every conversation carrying a bookmark tag,
* for tag-scoped writes that bypass `saveConvo`/`applyForcedRetention` global tag renames
* and deletes that `Conversation.updateMany` the tag on/off existing chats. Without this an
* older permanent chat touched only by a tag change after an install switches to ephemeral
* would stay visible and never expire. A no-op outside forced retention.
*/
async function applyForcedRetentionToTag(
{ userId, interfaceConfig }: { userId: string; interfaceConfig?: AppConfig['interfaceConfig'] },
{ tag }: { tag: string },
metadata?: { context?: string },
): Promise<void> {
if (!isForcedTemporaryRetention(interfaceConfig?.retentionMode)) {
return;
}
if (typeof tag !== 'string' || tag.length === 0) {
logger.warn(
`[applyForcedRetentionToTag] Ignoring non-string tag (context: ${metadata?.context ?? 'n/a'})`,
);
return;
}
let forcedExpiredAt: Date;
try {
forcedExpiredAt = createTempChatExpirationDate(interfaceConfig);
} catch (err) {
logger.error('Error creating temporary chat expiration date:', err);
logger.info(`---\`applyForcedRetentionToTag\` context: ${metadata?.context}`);
forcedExpiredAt = createFallbackRetentionDate();
}
const Message = mongoose.models.Message as Model<IMessage>;
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
const File = mongoose.models.File as Model<IMongoFile>;
await cascadeForcedRetentionByTag(
Conversation,
Message,
SharedLink,
File,
userId,
tag,
forcedExpiredAt,
);
await refreshForcedRetentionProjectStats(userId, { tags: tag } as FilterQuery<IConversation>);
}
/**
* Deletes messages in a conversation since a specific message.
*/
@ -839,8 +622,6 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
updateMessageText,
updateToolCallResult,
updateMessage,
applyForcedRetention,
applyForcedRetentionToTag,
deleteMessagesSince,
getMessages,
getMessage,

View file

@ -1,14 +1,38 @@
import type { FilterQuery, Model } from 'mongoose';
import type { AppConfig, IConversation, IMessage, IMongoFile, ISharedLink } from '~/types';
import { ResourceType, RetentionMode } from 'librechat-data-provider';
import type { FilterQuery, Model, Types } from 'mongoose';
import type {
AppConfig,
IAclEntry,
IConversation,
IMessage,
IMongoFile,
ISharedLink,
} from '~/types';
import type { IConversationTag } from '~/schema/conversationTag';
import { createTempChatExpirationDate, DEFAULT_RETENTION_HOURS } from './tempChatRetention';
import { tenantSafeBulkWrite } from './tenantBulkWrite';
import { isValidObjectIdString } from './objectId';
import logger from '~/config/winston';
export type RetentionFilterDocument = {
isTemporary?: boolean | null;
expiredAt?: Date | null;
};
type RetentionLogger = {
error: (message: string, error?: unknown) => void;
};
type ApplyForcedRetentionDependencies = {
logger: RetentionLogger;
refreshProjectStats: (userId: string, projectId: string) => Promise<unknown>;
};
export type ApplyForcedRetention = (
conversationId: string,
userId: string,
appConfig?: AppConfig['interfaceConfig'],
) => Promise<Date | null>;
export const activeExpirationFilter = <
T extends RetentionFilterDocument = RetentionFilterDocument,
>(): FilterQuery<T> =>
@ -34,616 +58,205 @@ export const buildRetentionVisibilityFilter = <
export const createFallbackRetentionDate = (now: number = Date.now()): Date =>
new Date(now + DEFAULT_RETENTION_HOURS * 60 * 60 * 1000);
/**
* Resolves the forced-retention deadline from the interface config, falling back to the default
* window when the configured retention hours cannot be computed.
*/
export const resolveForcedRetentionDate = (
interfaceConfig?: AppConfig['interfaceConfig'],
const expirationPipeline = (expiredAt: Date, includeTemporary = false) => [
{
$set: {
...(includeTemporary ? { isTemporary: true } : {}),
expiredAt: { $min: [{ $ifNull: ['$expiredAt', expiredAt] }, expiredAt] },
},
},
];
const resolveForcedRetentionDate = (
appConfig: AppConfig['interfaceConfig'] | undefined,
logger: RetentionLogger,
): Date => {
try {
return createTempChatExpirationDate(interfaceConfig);
} catch (err) {
logger.error('Error creating forced retention expiration date:', err);
return createTempChatExpirationDate(appConfig);
} catch (error) {
logger.error('Error creating forced-retention expiration date:', error);
return createFallbackRetentionDate();
}
};
/**
* Matches retention documents that do not yet conform to a forced (ephemeral) deadline:
* not temporary, missing an expiration, or expiring later than the forced window. The
* last clause re-caps documents carried over from a longer policy (`all`, or a longer
* `temporary` TTL) while leaving already-conforming temporary documents untouched.
*/
export const forcedRetentionGapFilter = <
T extends RetentionFilterDocument = RetentionFilterDocument,
>(
forcedExpiredAt: Date,
): FilterQuery<T> =>
({
$or: [
{ isTemporary: { $ne: true } },
{ expiredAt: null },
{ expiredAt: { $gt: forcedExpiredAt } },
],
}) as FilterQuery<T>;
/**
* In-memory counterpart of {@link forcedRetentionGapFilter} for a conversation's prior
* state: true when the parent must be re-capped to the forced deadline.
*/
export const conversationNeedsForcedRetention = (
parent: RetentionFilterDocument | null | undefined,
forcedExpiredAt: Date,
): boolean => {
if (parent == null) {
return false;
}
if (parent.isTemporary !== true || parent.expiredAt == null) {
return true;
}
return parent.expiredAt.getTime() > forcedExpiredAt.getTime();
};
export const capForcedRetentionExpiry = (
expiredAt: Date | null | undefined,
forcedExpiredAt: Date,
): Date => {
if (!(expiredAt instanceof Date)) {
return forcedExpiredAt;
}
const existingTime = expiredAt.getTime();
if (!Number.isNaN(existingTime) && existingTime < forcedExpiredAt.getTime()) {
return expiredAt;
}
return forcedExpiredAt;
};
/**
* Applies forced-retention deadlines to a conversation's messages that do not yet
* conform to the forced window.
*
* Forced (ephemeral) retention must cover existing messages too. A conversation that
* predates the mode keeps non-conforming messages `expiredAt: null` permanent messages,
* `isTemporary: false` messages carried over from `all` retention, or temporary messages
* whose `expiredAt` is later than a newly shortened window that would otherwise outlive
* the converted conversation. The gap filter pulls all of them onto the ephemeral schedule
* and stays a no-op once a conversation already conforms.
*
* Each message keeps its own earlier deadline: a carried-over message whose per-message TTL
* already expires sooner than the forced window is marked temporary but keeps its `expiredAt`,
* so converting the conversation never extends data that was already scheduled to expire
* sooner. A permanent message (`expiredAt` null/missing) instead receives the forced deadline
* (`$ifNull` guards `$min` from selecting the null), so the TTL index can remove it.
*/
export const forceConversationMessagesTemporary = async (
const collectReferencedFileIds = async (
Message: Model<IMessage>,
userId: string,
conversationId: string,
expiredAt: Date,
): Promise<number> => {
const result = await Message.updateMany(
{ conversationId, user: userId, ...forcedRetentionGapFilter<IMessage>(expiredAt) },
[
{
$set: {
isTemporary: true,
expiredAt: { $min: [{ $ifNull: ['$expiredAt', expiredAt] }, expiredAt] },
},
},
],
);
return result.modifiedCount ?? 0;
};
/**
* Caps a conversation's shared links to the forced deadline. A share embeds a snapshot of
* the conversation (message refs and file snapshots) and its TTL index keys off `expiredAt`
* alone, so a permanent share (`expiredAt: null`) created before forced retention would stay
* publicly readable after the conversation and messages expire. Only links with no
* expiration or a later one are touched, so it is a no-op once a conversation conforms.
*/
export const capConversationSharedLinks = async (
SharedLink: Model<ISharedLink>,
userId: string,
conversationId: string,
forcedExpiredAt: Date,
): Promise<number> => {
const result = await SharedLink.updateMany(
{
conversationId,
user: userId,
$or: [{ expiredAt: null }, { expiredAt: { $gt: forcedExpiredAt } }],
},
{ $set: { expiredAt: forcedExpiredAt } },
);
return result.modifiedCount ?? 0;
};
/**
* Collects the file ids a set of conversations references. Message-attachment uploads create
* File rows without a `conversationId` they are referenced only from `Message.files[].file_id`,
* `Message.attachments[].file_id` (tool/agent outputs), and the conversation's own `files`
* array so conversation-scoped file caps must also target these ids. `seedFileIds` takes the
* conversations' `files` arrays; message references are read in one pass over the conversations'
* messages.
*/
export const collectConversationFileIds = async (
Message: Model<IMessage>,
userId: string,
conversationIds: string[],
seedFileIds?: Iterable<string | null | undefined>,
parent: Pick<IConversation, 'files' | 'file_ids'>,
): Promise<string[]> => {
const fileIds = new Set<string>();
const addFileIds = (references?: Array<{ file_id?: unknown } | null>) => {
const fileIds = new Set<string>([...(parent.files ?? []), ...(parent.file_ids ?? [])]);
const messages = await Message.find(
{
user: userId,
conversationId,
$or: [{ files: { $exists: true, $ne: null } }, { attachments: { $exists: true, $ne: null } }],
},
'files attachments',
).lean<
Array<{
files?: Array<{ file_id?: unknown } | null>;
attachments?: Array<{ file_id?: unknown } | null>;
}>
>();
const addReferences = (references?: Array<{ file_id?: unknown } | null>) => {
for (const reference of references ?? []) {
const fileId = reference?.file_id;
if (typeof fileId === 'string' && fileId.length > 0) {
fileIds.add(fileId);
if (typeof reference?.file_id === 'string' && reference.file_id.length > 0) {
fileIds.add(reference.file_id);
}
}
};
for (const fileId of seedFileIds ?? []) {
if (typeof fileId === 'string' && fileId.length > 0) {
fileIds.add(fileId);
}
for (const message of messages) {
addReferences(message.files);
addReferences(message.attachments);
}
if (conversationIds.length > 0) {
const messages = await Message.find(
{
user: userId,
conversationId: { $in: conversationIds },
$or: [
{ files: { $exists: true, $ne: null } },
{ attachments: { $exists: true, $ne: null } },
],
},
'files attachments',
).lean<
Array<{
files?: Array<{ file_id?: unknown } | null>;
attachments?: Array<{ file_id?: unknown } | null>;
}>
>();
for (const message of messages) {
addFileIds(message.files);
addFileIds(message.attachments);
}
}
return [...fileIds];
return [...fileIds].filter(Boolean);
};
/**
* Concatenates a conversation's own file references: `files` (regular uploads) and `file_ids`
* (Assistants thread uploads persisted by saveUserMessage/syncMessages). Used to seed
* {@link collectConversationFileIds} so both reference styles are capped.
*/
export const conversationSeedFileIds = (convo: {
files?: string[] | null;
file_ids?: string[] | null;
}): string[] => [...(convo.files ?? []), ...(convo.file_ids ?? [])];
/** Builds an owner-scoped file-cap filter for conversation and referenced-file matches. */
const ownedFileScope = (
const createOwnedFileScope = (
userId: string,
conversationId: string | { $in: string[] },
conversationId: string,
fileIds: string[],
): FilterQuery<IMongoFile> | null => {
if (!isValidObjectIdString(userId)) {
return null;
}
const conversationScope = { conversationId, user: userId };
if (fileIds.length === 0) {
return conversationScope;
}
return {
$or: [conversationScope, { file_id: { $in: fileIds }, user: userId }],
};
$or: [
{ user: userId, conversationId },
...(fileIds.length > 0 ? [{ user: userId, file_id: { $in: fileIds } }] : []),
],
} as FilterQuery<IMongoFile>;
};
/**
* Caps a conversation's uploaded files to the forced deadline. Files use a retention-scoped
* `expiredAt` swept by application code (`getExpiredFiles` only sweeps files whose own `expiredAt`
* is set), so a permanent file (`expiredAt: null`) uploaded before forced retention would linger
* in storage after the conversation and messages TTL out. Only files with no expiration or a later
* one are touched, so it is a no-op once a conversation conforms and never extends a file that
* already expires sooner. Under ephemeral retention every conversation-scoped file is meant to
* expire (persistent agent files are not retained), so no agent-file exclusion is needed here.
*
* Matches by `conversationId` plus any referenced `fileIds`. Both branches are scoped by
* `File.user` because conversation ids are unique only together with their owner and tenant,
* while referenced ids can be caller-supplied.
* A shared file referenced from several chats is capped to the earliest converting chat's
* deadline, consistent with cap-don't-extend.
*/
export const capConversationFiles = async (
File: Model<IMongoFile>,
userId: string,
conversationId: string,
forcedExpiredAt: Date,
fileIds: string[] = [],
): Promise<number> => {
const scope = ownedFileScope(userId, conversationId, fileIds);
if (scope == null) {
return 0;
}
const result = await File.updateMany(
{
$and: [scope, { $or: [{ expiredAt: null }, { expiredAt: { $gt: forcedExpiredAt } }] }],
} as FilterQuery<IMongoFile>,
{ $set: { expiredAt: forcedExpiredAt } },
);
return result.modifiedCount ?? 0;
};
/**
* Caps a message-only forced save to a parent that already expires sooner than the freshly
* computed window. Returns the parent's earlier deadline (so the message cannot outlive it)
* and backfills the conversation's lagging messages to that deadline the cascade leaves
* an already-conforming parent untouched, so older `expiredAt: null`/later messages would
* otherwise survive the parent's TTL. Returns the forced window unchanged when no earlier
* parent deadline applies.
*
* Any active earlier deadline is honored regardless of `isTemporary`: an `all`-mode parent
* carried over with a sooner `expiredAt` must not be extended to the fresh window just
* because it is not yet temporary the cascade converts it afterward using this deadline.
*/
export const capForcedRetentionToParent = async (
const reconcileTagCounts = async (
Conversation: Model<IConversation>,
Message: Model<IMessage>,
SharedLink: Model<ISharedLink>,
userId: string,
conversationId: string,
forcedExpiredAt: Date,
): Promise<Date> => {
const parent = await Conversation.findOne({ conversationId, user: userId }, 'expiredAt').lean<{
expiredAt?: Date | null;
} | null>();
const expiredAt = capForcedRetentionExpiry(parent?.expiredAt, forcedExpiredAt);
if (expiredAt !== forcedExpiredAt) {
await forceConversationMessagesTemporary(Message, userId, conversationId, expiredAt);
await capConversationSharedLinks(SharedLink, userId, conversationId, expiredAt);
}
return expiredAt;
};
/**
* Converts or re-caps a parent conversation to the forced deadline and, when that first
* brings the conversation into the forced window, backfills its lagging messages, shares, and
* files. Shared by every forced-retention message-write path so a single conversation/message
* rule is enforced regardless of which save touched the chat. Returns whether the parent row
* was converted, so callers can refresh caches (e.g. project stats) that a visibility flip
* invalidates.
*/
export const cascadeForcedConversationRetention = async (
Conversation: Model<IConversation>,
Message: Model<IMessage>,
SharedLink: Model<ISharedLink>,
File: Model<IMongoFile>,
userId: string,
conversationId: string,
forcedExpiredAt: Date,
): Promise<boolean> => {
const parent = await Conversation.findOne(
{ conversationId, user: userId },
'isTemporary expiredAt files file_ids',
).lean<(RetentionFilterDocument & { files?: string[]; file_ids?: string[] }) | null>();
if (parent == null) {
return false;
}
const expiredAt = capForcedRetentionExpiry(parent.expiredAt, forcedExpiredAt);
const needsConversion = conversationNeedsForcedRetention(parent, expiredAt);
/**
* Referenced file ids (message-attachment rows carry no conversationId) are collected only at
* conversion time: post-conversion uploads always receive a deadline at upload, so the id
* scan over the chat's messages is not needed on every conforming-parent write.
*/
const fileIds = needsConversion
? await collectConversationFileIds(
Message,
userId,
[conversationId],
conversationSeedFileIds(parent),
)
: [];
/**
* Cap the dependent messages, shares, and files independently of the parent gap check, and
* before the parent conversion. An already-conforming parent can still own lagging children
* (permanent shares or later-window files created before the mode switch, or left by a partial
* earlier backfill), and a child failure must leave the parent non-conforming so a later
* forced-retention write re-runs the whole cascade. Each cap is an indexed no-op once the
* chat's children conform.
*/
await forceConversationMessagesTemporary(Message, userId, conversationId, expiredAt);
await capConversationSharedLinks(SharedLink, userId, conversationId, expiredAt);
await capConversationFiles(File, userId, conversationId, expiredAt, fileIds);
if (!needsConversion) {
return false;
}
const convoResult = await Conversation.updateOne(
{ conversationId, user: userId, ...forcedRetentionGapFilter<IConversation>(expiredAt) },
{ $set: { isTemporary: true, expiredAt } },
);
return (convoResult.modifiedCount ?? 0) > 0;
};
/**
* Bulk-applies forced retention to the user's conversations selected by `conversationMatch`
* (a bookmark tag, a chat project, etc.). Writes that touch these rows directly
* (`Conversation.updateMany`) without setting `isTemporary`/`expiredAt` would otherwise leave a
* permanent chat visible and non-expiring after an install switched to ephemeral. Chats are
* bucketed by their capped deadline so each bucket converts the chats, backfills their messages,
* and caps their shares and files in one pass; the gap filter keeps it a no-op for chats that
* already conform and never extends a chat that already expires sooner.
*/
const cascadeForcedRetentionForConversationSet = async (
Conversation: Model<IConversation>,
Message: Model<IMessage>,
SharedLink: Model<ISharedLink>,
File: Model<IMongoFile>,
userId: string,
conversationMatch: FilterQuery<IConversation>,
forcedExpiredAt: Date,
tags: string[],
): Promise<void> => {
const conversations = await Conversation.find(
{ user: userId, ...conversationMatch } as FilterQuery<IConversation>,
'conversationId isTemporary expiredAt files file_ids',
).lean<
Array<
RetentionFilterDocument & { conversationId?: string; files?: string[]; file_ids?: string[] }
>
>();
if (conversations.length === 0) {
const uniqueTags = [...new Set(tags.filter(Boolean))];
if (uniqueTags.length === 0) {
return;
}
const retentionBuckets = new Map<
number,
{ expiredAt: Date; conversationIds: string[]; seedFileIds: string[] }
>();
for (const convo of conversations) {
if (typeof convo.conversationId !== 'string' || convo.conversationId.length === 0) {
continue;
}
const expiredAt = capForcedRetentionExpiry(convo.expiredAt, forcedExpiredAt);
const key = expiredAt.getTime();
const bucket = retentionBuckets.get(key) ?? {
expiredAt,
conversationIds: [],
seedFileIds: [],
};
bucket.conversationIds.push(convo.conversationId);
for (const fileId of conversationSeedFileIds(convo)) {
bucket.seedFileIds.push(fileId);
}
retentionBuckets.set(key, bucket);
}
for (const { expiredAt, conversationIds, seedFileIds } of retentionBuckets.values()) {
await Conversation.updateMany(
{
const counts = await Conversation.aggregate<{ _id: string; count: number }>([
{
$match: {
user: userId,
conversationId: { $in: conversationIds },
...forcedRetentionGapFilter<IConversation>(expiredAt),
tags: { $in: uniqueTags },
...buildRetentionVisibilityFilter<IConversation>(),
},
{ $set: { isTemporary: true, expiredAt } },
);
await Message.updateMany(
{
user: userId,
conversationId: { $in: conversationIds },
...forcedRetentionGapFilter<IMessage>(expiredAt),
},
{
$project: {
tags: { $setIntersection: [{ $ifNull: ['$tags', []] }, uniqueTags] },
},
[
{
$set: {
isTemporary: true,
expiredAt: { $min: [{ $ifNull: ['$expiredAt', expiredAt] }, expiredAt] },
},
},
],
);
await SharedLink.updateMany(
{
user: userId,
conversationId: { $in: conversationIds },
$or: [{ expiredAt: null }, { expiredAt: { $gt: expiredAt } }],
},
{ $unwind: '$tags' },
{ $group: { _id: '$tags', count: { $sum: 1 } } },
]);
const countByTag = new Map(counts.map(({ _id, count }) => [_id, count]));
const ConversationTag = Conversation.db.models.ConversationTag as Model<IConversationTag>;
await tenantSafeBulkWrite(
ConversationTag,
uniqueTags.map((tag) => ({
updateOne: {
filter: { user: userId, tag },
update: { $set: { count: countByTag.get(tag) ?? 0 } },
},
{ $set: { expiredAt } },
);
const fileIds = await collectConversationFileIds(Message, userId, conversationIds, seedFileIds);
const fileScope = ownedFileScope(userId, { $in: conversationIds }, fileIds);
if (fileScope == null) {
continue;
}
await File.updateMany(
{
$and: [fileScope, { $or: [{ expiredAt: null }, { expiredAt: { $gt: expiredAt } }] }],
} as FilterQuery<IMongoFile>,
{ $set: { expiredAt } },
);
}
})),
);
};
/**
* Bulk-applies forced retention to every conversation carrying a bookmark tag. A tag rename or
* delete writes conversation rows directly without setting `isTemporary`/`expiredAt`, so a
* permanent chat tagged before the install switched to ephemeral would otherwise stay visible
* and never expire.
*/
export const cascadeForcedRetentionByTag = (
Conversation: Model<IConversation>,
Message: Model<IMessage>,
SharedLink: Model<ISharedLink>,
File: Model<IMongoFile>,
userId: string,
tag: string,
forcedExpiredAt: Date,
): Promise<void> =>
cascadeForcedRetentionForConversationSet(
Conversation,
Message,
SharedLink,
File,
userId,
{ tags: tag } as FilterQuery<IConversation>,
forcedExpiredAt,
);
/**
* Bulk-applies forced retention to every conversation in a chat project. Assigning a chat to a
* project, removing it, or deleting the project rewrites conversation rows without setting
* `isTemporary`/`expiredAt`, so a permanent chat organized after the install switched to
* ephemeral would otherwise stay visible and never expire.
*/
export const cascadeForcedRetentionByProject = (
Conversation: Model<IConversation>,
Message: Model<IMessage>,
SharedLink: Model<ISharedLink>,
File: Model<IMongoFile>,
userId: string,
chatProjectId: string,
forcedExpiredAt: Date,
): Promise<void> =>
cascadeForcedRetentionForConversationSet(
Conversation,
Message,
SharedLink,
File,
userId,
{ chatProjectId } as FilterQuery<IConversation>,
forcedExpiredAt,
);
/**
* One-time backfill of forced (ephemeral) retention over pre-existing data. Convert-on-touch
* only converts conversations that are subsequently written, so enabling ephemeral mode on a
* deployment with existing chats leaves untouched permanent rows visible and non-expiring.
*
* Streams every conversation that does not yet conform to the forced window and converts it,
* its messages, its shares, and its uploaded files one conversation at a time. Each conversation
* is capped to the earlier of its own deadline and the forced window, and its messages, shares,
* and files are capped to that same per-conversation deadline, so the sweep never extends data
* that already expires sooner and never lets a dependent record outlive its conversation. It is
* idempotent: re-running skips conversations that already conform.
*
* Converted conversations become `isTemporary: true`, which `visibleProjectConversationFilter`
* hides, so each converted chat's project membership is collected in `projects` for the caller
* to recompute cached project stats (the sweep cannot refresh them itself without a circular
* dependency on the chat-project methods).
*
* Already-conforming temporary conversations are swept too: their dependent shares, files, and
* messages can still lag (permanent shares or later-window records created before the mode
* switch), so an alignment pass caps those children to each parent's own deadline. `aligned`
* counts the conversations whose children needed changes.
*/
export const sweepForcedRetention = async (
Conversation: Model<IConversation>,
Message: Model<IMessage>,
SharedLink: Model<ISharedLink>,
File: Model<IMongoFile>,
forcedExpiredAt: Date,
): Promise<{
conversations: number;
aligned: number;
errors: number;
projects: Array<{ user: string; chatProjectId: string }>;
}> => {
const result = { conversations: 0, aligned: 0, errors: 0 };
const projectKeys = new Set<string>();
const projects: Array<{ user: string; chatProjectId: string }> = [];
export function createApplyForcedRetention(
mongoose: typeof import('mongoose'),
{ logger, refreshProjectStats }: ApplyForcedRetentionDependencies,
): ApplyForcedRetention {
/**
* Alignment pass first: conforming parents are excluded from the gap-filtered conversion
* cursor below, and running this before the conversion avoids revisiting the conversations
* that pass converts (their children are capped at conversion time).
* Applies the complete forced-retention invariant for one owned conversation.
*
* `expiredAt` is a monotonic cap: the conversation and every dependent record keep an
* earlier existing deadline, while null/missing/later deadlines move to the configured
* forced deadline. Every expiry mutation is a server-side aggregation-pipeline `$min`;
* no value derived from a prior read is ever written back with `$set`.
*
* The operation is idempotent and always aligns every child, even when the parent already
* conforms. A partial failure therefore needs only another call; parent conformity is never
* used as a sentinel that would suppress retrying child work.
*/
const alignCursor = Conversation.find({
isTemporary: true,
expiredAt: { $ne: null, $lte: forcedExpiredAt },
} as FilterQuery<IConversation>)
.select('conversationId user expiredAt files file_ids')
.lean()
.cursor();
for await (const convo of alignCursor) {
const { conversationId, user, expiredAt } = convo;
async function applyForcedRetention(
conversationId: string,
userId: string,
appConfig?: AppConfig['interfaceConfig'],
): Promise<Date | null> {
if (appConfig?.retentionMode !== RetentionMode.EPHEMERAL) {
return null;
}
if (
typeof conversationId !== 'string' ||
!conversationId ||
!user ||
!(expiredAt instanceof Date)
conversationId.length === 0 ||
typeof userId !== 'string' ||
userId.length === 0
) {
continue;
return null;
}
try {
const fileIds = await collectConversationFileIds(
Message,
user,
[conversationId],
conversationSeedFileIds(convo),
);
const changed =
(await forceConversationMessagesTemporary(Message, user, conversationId, expiredAt)) +
(await capConversationSharedLinks(SharedLink, user, conversationId, expiredAt)) +
(await capConversationFiles(File, user, conversationId, expiredAt, fileIds));
if (changed > 0) {
result.aligned += 1;
}
} catch {
result.errors += 1;
const proposedExpiredAt = resolveForcedRetentionDate(appConfig, logger);
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const Message = mongoose.models.Message as Model<IMessage>;
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
const File = mongoose.models.File as Model<IMongoFile>;
const parent = await Conversation.findOneAndUpdate(
{ conversationId, user: userId },
expirationPipeline(proposedExpiredAt, true),
{
new: true,
projection: {
expiredAt: 1,
files: 1,
file_ids: 1,
tags: 1,
chatProjectId: 1,
},
},
).lean<Pick<
IConversation,
'expiredAt' | 'files' | 'file_ids' | 'tags' | 'chatProjectId'
> | null>();
const expiredAt = parent?.expiredAt instanceof Date ? parent.expiredAt : proposedExpiredAt;
const [fileIds, sharedLinks] = await Promise.all([
collectReferencedFileIds(Message, userId, conversationId, parent ?? {}),
SharedLink.find({ conversationId, user: userId }, '_id').lean<
Array<{ _id: Types.ObjectId }>
>(),
]);
const sharedLinkIds = sharedLinks.map(({ _id }) => _id);
const fileScope = createOwnedFileScope(userId, conversationId, fileIds);
const AclEntry = SharedLink.db.models.AclEntry as Model<IAclEntry>;
await Promise.all([
Message.updateMany({ conversationId, user: userId }, expirationPipeline(expiredAt, true)),
...(sharedLinkIds.length > 0
? [
SharedLink.updateMany({ _id: { $in: sharedLinkIds } }, expirationPipeline(expiredAt)),
AclEntry.updateMany(
{
resourceType: ResourceType.SHARED_LINK,
resourceId: { $in: sharedLinkIds },
},
expirationPipeline(expiredAt),
),
]
: []),
...(fileScope != null ? [File.updateMany(fileScope, expirationPipeline(expiredAt))] : []),
]);
await reconcileTagCounts(Conversation, userId, parent?.tags ?? []);
if (typeof parent?.chatProjectId === 'string' && parent.chatProjectId.length > 0) {
await refreshProjectStats(userId, parent.chatProjectId);
}
return expiredAt;
}
const cursor = Conversation.find(forcedRetentionGapFilter<IConversation>(forcedExpiredAt))
.select('_id conversationId user expiredAt chatProjectId files file_ids')
.lean()
.cursor();
for await (const convo of cursor) {
const { conversationId, user, chatProjectId } = convo;
if (typeof conversationId !== 'string' || !conversationId || !user) {
continue;
}
try {
const expiredAt = capForcedRetentionExpiry(convo.expiredAt, forcedExpiredAt);
const fileIds = await collectConversationFileIds(
Message,
user,
[conversationId],
conversationSeedFileIds(convo),
);
/**
* Convert the dependent messages, shares, and files before marking the conversation itself
* conforming. If a child backfill throws, the conversation stays non-conforming so the
* gap-filtered query picks it up again on a re-run, keeping the sweep safe to repeat.
*/
await forceConversationMessagesTemporary(Message, user, conversationId, expiredAt);
await capConversationSharedLinks(SharedLink, user, conversationId, expiredAt);
await capConversationFiles(File, user, conversationId, expiredAt, fileIds);
const convoResult = await Conversation.updateOne(
{ _id: convo._id, ...forcedRetentionGapFilter<IConversation>(expiredAt) },
{ $set: { isTemporary: true, expiredAt } },
);
if ((convoResult.modifiedCount ?? 0) === 0) {
continue;
}
result.conversations += 1;
if (typeof chatProjectId === 'string' && chatProjectId.length > 0) {
const key = `${user}|${chatProjectId}`;
if (!projectKeys.has(key)) {
projectKeys.add(key);
projects.push({ user: String(user), chatProjectId });
}
}
} catch {
result.errors += 1;
}
}
return { ...result, projects };
};
return applyForcedRetention;
}