🗄️ feat: Archive All Chats From Data Controls (#14885)

* feat: archive all chats from data controls

Adds an "Archive all chats" row under Data controls > Your data, next to
Shared links, with a confirmation dialog. It calls a new
POST /api/convos/archive/all endpoint backed by archiveAllConvos, which
archives every conversation currently visible to the user in a single
updateMany and refreshes the stats of every chat project the archived
conversations belonged to.

Temporary and retention-expired conversations are skipped: they are
already hidden from the chat list, so archiving them would only surface
them in the archived view. The update runs with timestamps disabled so
each conversation keeps its own updatedAt and the archived list stays
sorted by real activity.

Archiving a conversation now also drops the new-chat message cache alias
for it. A chat's first turn writes the same message array under both the
conversation key and the new-chat key, so without this the messages of a
just-archived chat kept rendering on the new chat screen until a reload.
Deleting already handled this; archiving did not.

* fix: keep archive-all state consistent

* fix: drop stale detail caches after bulk archive

* fix: harden archive-all request handling

* fix: reconcile archive batch failures

* Fix project stats refresh races and archive route boundary

* Fix archive-all review findings

* Fix archive scan index and partial-batch stats refresh

Reconcile project stats for already-committed archive batches when a later
batch fails, and index the archive scan as { user, _id } so non-tenant
pagination can use _id order.

* Fix Recoil reset after a partial archive-all failure

Refetch the submitted conversation on error and start a new chat only
when that conversation is still active and already archived.

* Fix archive recovery from resetting a newly opened chat

Re-read the active Recoil conversation after the archive-state lookup
resolves, so a slow getConversationById cannot start a new chat if the
user already opened another conversation.

* Fix project-stat reconciliation after archive races

Keep retrying optimistic project-stat writes instead of returning a
stale document after three lost CAS attempts, and retry destination
project discovery after a transient distinct failure.

* Fix archive reset and project-count increment races

Leave already-archived chats open after archive-all, recount new
project conversations instead of incrementing, and skip a delayed
increment when a concurrent refresh already recorded that chat.

* Recover destination projects after discovery retries exhaust

Keep committed conversation IDs when post-archive distinct fails, then
rediscover those projects in finally so a moved conversation's
destination still gets reconciled after the error is rethrown.

* Fix archive-all recovery batching and remount pending state

Recover destination projects in 500-id chunks so the final lookup
cannot exceed Mongo's command size, and share archive-all pending
state through a mutation key so Settings remounts stay disabled.

* Stamp bulk-archived chats and refresh the pinned cache

Bulk archive wrote only isArchived, so the archived table dated every
swept chat by createdAt and the default archivedAt sort dropped the whole
run into the legacy null group. Stamp one timestamp for the sweep; the
filter only matches unarchived chats, so an existing stamp cannot move,
and timestamps: false still preserves each updatedAt.

The pinned section fetches on its own key with a five-minute stale time,
so an archived pin kept rendering in the sidebar until that expired.
Invalidate it alongside the other lists on both success and failure.

Also drop the async from the failing-batch updateMany mock: its
Promise<never> is not assignable to the Query return type, while a plain
synchronous throw types as never.

* Bound archive recovery state with the sweep marker

Recovery held every committed conversation id for the life of the
request so the finally block could re-run project discovery after an
in-loop distinct gave up. Slicing that array into 500-id queries capped
the BSON command size but not the heap, so a very large history could
exhaust a worker mid-archive.

The archivedAt stamp already identifies exactly what this call
committed, so recovery is now one distinct scoped to it. That filter is
a prefix of the existing user/isArchived/archivedAt index, and the two
discovery call sites collapse into one filter-taking helper.

* Reconcile archive stats when a write outcome is unknown

A batch that commits but whose result never returns, a stepdown or a
connection drop between commit and acknowledgement, left archivedCount
at zero, so the finally block skipped both marker recovery and the stats
refresh. The chats were archived, so no retry could find them again: the
sweep filter no longer matches them and their projects kept stale
counts.

Both now key off the write attempt rather than the returned count.
Nothing else needs to change, because the marker is stamped by the same
write whose result went missing.

* Retry dropped project refreshes and guard stale pointer writes

Two ways a project could keep stale stats after archive-all.

A refresh that rejected was logged and dropped for good. Its chats are
archived, so no retry of archive-all can find them again to recompute
against, and the likeliest rejection is the recoverable one:
refreshChatProjectStatsForUser gives up when the project changed under
every compare-and-set attempt. Failures are now collected and replayed
once the rest of the run has stopped competing with them.

A save already in flight could also undo the sweep. Its conversation
document still said visible, so its tail took the pointer branch and
wrote lastConversationId back to a chat the sweep had just archived,
leaving the project advertising activity on a chat the workspace hides.
The pointer write now confirms the chat is still visible first, and
recomputes the project when it is not.

* Verify project pointers after the write, not before

Checking visibility before the pointer write only moved the race earlier:
a sweep landing between the check and the update still archived the chat
and cleared the project, and the write then restored it as
lastConversationId.

The check now runs after the write and repairs instead of preventing. A
sweep that lands earlier is caught here; one that lands later refreshes
the project itself, and refreshChatProjectStatsForUser compare-and-sets,
so it cannot commit a count it read before this write. Same single
indexed read as the check it replaces.
This commit is contained in:
Marco Beretta 2026-08-17 04:11:57 +02:00 committed by GitHub
parent 7ebf6b2548
commit fdc9c77f6e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 2166 additions and 44 deletions

View file

@ -1,4 +1,8 @@
const archiveAllHandler = jest.fn();
module.exports = {
archiveAllHandler,
agents: () => ({ sleep: jest.fn() }),
api: (overrides = {}) => ({
@ -21,6 +25,13 @@ module.exports = {
})),
logAxiosError: jest.fn(),
restoreTenantContextFromReq: jest.fn((req, res, next) => next()),
createArchiveAllHandler: jest.fn(({ archiveAllConvos }) => {
archiveAllHandler.mockImplementation(async (req, res) => {
const result = await archiveAllConvos(req.user.id);
return res.status(200).json(result);
});
return archiveAllHandler;
}),
deleteConvoSharedLinksWithCleanup: jest.fn(),
deleteAllSharedLinksWithCleanup: jest.fn(),
deleteAgentCheckpoints: jest.fn(),
@ -64,6 +75,7 @@ module.exports = {
getConvosByCursor: jest.fn(),
getConvo: jest.fn(),
deleteConvos: jest.fn(),
archiveAllConvos: jest.fn(),
saveConvo: jest.fn(),
setConvoPinned: jest.fn(),
deleteAllSharedLinks: jest.fn(),

View file

@ -2,6 +2,7 @@ const express = require('express');
const request = require('supertest');
const MOCKS = '../__test-utils__/convos-route-mocks';
const { archiveAllHandler } = require(MOCKS);
jest.mock('@librechat/agents', () => require(MOCKS).agents());
jest.mock('@librechat/api', () => require(MOCKS).api());
@ -704,6 +705,21 @@ describe('Convos Routes', () => {
});
});
describe('POST /archive/all', () => {
const { archiveAllConvos } = require('~/models');
it('delegates archive-all requests through the package API handler', async () => {
archiveAllConvos.mockResolvedValue({ archivedCount: 4 });
const response = await request(app).post('/api/convos/archive/all');
expect(response.status).toBe(200);
expect(response.body).toEqual({ archivedCount: 4 });
expect(archiveAllHandler).toHaveBeenCalledTimes(1);
expect(archiveAllConvos).toHaveBeenCalledWith('test-user-123');
});
});
describe('POST /convos/pin', () => {
const mockConversationId = 'conv-123';
const { setConvoPinned } = require('~/models');

View file

@ -4,6 +4,7 @@ const { sleep } = require('@librechat/agents');
const {
isEnabled,
deleteAgentCheckpoints,
createArchiveAllHandler,
resolveImportMaxFileSize,
restoreTenantContextFromReq,
deleteAllSharedLinksWithCleanup,
@ -30,6 +31,7 @@ const assistantClients = {
};
const router = express.Router();
const archiveAllHandler = createArchiveAllHandler({ archiveAllConvos: db.archiveAllConvos });
router.use(requireJwtAuth);
const isValidProjectFilter = (projectId) =>
@ -230,6 +232,13 @@ router.post('/archive', validateConvoAccess, async (req, res) => {
}
});
/**
* Archives every conversation currently visible to the user.
* @route POST /archive/all
* @returns {object} 200 - The number of conversations archived.
*/
router.post('/archive/all', archiveAllHandler);
router.post('/pin', validateConvoAccess, async (req, res) => {
const { conversationId, pinned } = req.body?.arg ?? {};

View file

@ -20,6 +20,7 @@ import ConversationModeSwitch from '../SettingsTabs/Speech/ConversationModeSwitc
import EnableTwoFactorItem from '../SettingsTabs/Account/TwoFactorAuthentication';
import LangfuseConnection from '../SettingsTabs/Integrations/LangfuseConnection';
import ImportConversations from '../SettingsTabs/Data/ImportConversations';
import { ArchiveAllChats } from '../SettingsTabs/Data/ArchiveAllChats';
import { toggleControl, ThemeSetting, LangSetting } from './controls';
import BackupCodesItem from '../SettingsTabs/Account/BackupCodesItem';
import { EngineSTTSetting, EngineTTSSetting } from './SpeechControls';
@ -554,6 +555,14 @@ export const registry: SettingEntry[] = [
keywords: ['file', 'files', 'upload', 'uploads', 'storage', 'attachments'],
Component: ManageFiles,
},
{
id: 'archiveAllChats',
tab: DATA,
section: 'data',
labelKey: 'com_ui_settings_label_archive_all_chats',
keywords: ['archive', 'chats', 'conversations', 'bulk'],
Component: ArchiveAllChats,
},
// Data controls · API keys
{
id: 'providerApiKeys',

View file

@ -0,0 +1,147 @@
import React, { useRef, useState } from 'react';
import { useIsMutating } from '@tanstack/react-query';
import { Constants, MutationKeys, dataService } from 'librechat-data-provider';
import {
Label,
Button,
Spinner,
OGDialog,
OGDialogClose,
OGDialogTrigger,
useToastContext,
OGDialogTemplate,
} from '@librechat/client';
import type { TConversation } from 'librechat-data-provider';
import useGetConversation from '~/hooks/Conversations/useGetConversation';
import { useArchiveAllConversationsMutation } from '~/data-provider';
import { isTemporaryConversation } from '~/utils';
import useNewChat from '~/hooks/Chat/useNewChat';
import { NotificationSeverity } from '~/common';
import { useLocalize } from '~/hooks';
type SubmittedConversation = Pick<
TConversation,
'conversationId' | 'isArchived' | 'isTemporary' | 'expiredAt'
>;
const isSubmittedChatStillActive = (
submittedConversation: SubmittedConversation | null,
currentConversation: TConversation | null,
): submittedConversation is SubmittedConversation & { conversationId: string } =>
submittedConversation != null &&
submittedConversation.conversationId != null &&
submittedConversation.conversationId !== Constants.NEW_CONVO &&
submittedConversation.isArchived !== true &&
!isTemporaryConversation(submittedConversation) &&
currentConversation?.conversationId === submittedConversation.conversationId &&
currentConversation?.isArchived !== true &&
!isTemporaryConversation(currentConversation);
export const ArchiveAllChats = () => {
const localize = useLocalize();
const [open, setOpen] = useState(false);
const submittedConversationRef = useRef<SubmittedConversation | null>(null);
const getConversation = useGetConversation();
const { startNewChat } = useNewChat();
const { showToast } = useToastContext();
const pendingArchives = useIsMutating({
mutationKey: [MutationKeys.archiveAllConversations],
});
const archiveAllMutation = useArchiveAllConversationsMutation({
onSuccess: () => {
const submittedConversation = submittedConversationRef.current;
const currentConversation = getConversation();
submittedConversationRef.current = null;
if (isSubmittedChatStillActive(submittedConversation, currentConversation)) {
startNewChat();
}
showToast({
message: localize('com_ui_archive_all_success'),
severity: NotificationSeverity.SUCCESS,
showIcon: true,
});
},
onError: async () => {
const submittedConversation = submittedConversationRef.current;
const currentConversation = getConversation();
submittedConversationRef.current = null;
showToast({
message: localize('com_ui_archive_all_error'),
severity: NotificationSeverity.ERROR,
showIcon: true,
});
if (!isSubmittedChatStillActive(submittedConversation, currentConversation)) {
return;
}
try {
const persistedConversation = await dataService.getConversationById(
submittedConversation.conversationId,
);
if (
persistedConversation.isArchived === true &&
isSubmittedChatStillActive(submittedConversation, getConversation())
) {
startNewChat();
}
} catch {
// Leave Recoil alone when we cannot confirm this chat was archived.
}
},
});
const archiveAllChats = () => {
const conversation = getConversation();
submittedConversationRef.current = conversation?.conversationId
? {
conversationId: conversation.conversationId,
isArchived: conversation.isArchived,
isTemporary: conversation.isTemporary,
expiredAt: conversation.expiredAt,
}
: null;
archiveAllMutation.mutate();
};
const isArchivePending = archiveAllMutation.isLoading || pendingArchives > 0;
return (
<div className="flex items-center justify-between">
<Label id="archive-all-chats-label">{localize('com_nav_archive_all_chats')}</Label>
<OGDialog open={open} onOpenChange={setOpen}>
<OGDialogTrigger asChild>
<Button
aria-labelledby="archive-all-chats-label"
variant="outline"
disabled={isArchivePending}
onClick={() => setOpen(true)}
>
{localize('com_ui_archive')}
</Button>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_nav_confirm_archive_all')}
className="max-w-[450px]"
main={
<Label className="break-words">{localize('com_nav_archive_all_confirm_message')}</Label>
}
selection={
<OGDialogClose asChild>
<Button
aria-label={localize('com_ui_archive')}
aria-busy={isArchivePending}
disabled={isArchivePending}
variant="submit"
className="border-none font-normal max-sm:order-first max-sm:w-full sm:order-none"
onClick={archiveAllChats}
>
{isArchivePending ? <Spinner /> : localize('com_ui_archive')}
</Button>
</OGDialogClose>
}
/>
</OGDialog>
</div>
);
};

View file

@ -0,0 +1,292 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { ArchiveAllChats } from '../ArchiveAllChats';
const mockMutate = jest.fn();
const mockShowToast = jest.fn();
const mockStartNewChat = jest.fn();
const mockGetConversation = jest.fn();
const mockGetConversationById = jest.fn();
let mockIsLoading = false;
let mockPendingArchives = 0;
let mockOnSuccess: (() => void) | undefined;
let mockOnError: (() => void | Promise<void>) | undefined;
jest.mock('@librechat/client', () => {
const actual = jest.requireActual('@librechat/client');
return {
...actual,
useToastContext: () => ({ showToast: mockShowToast }),
};
});
jest.mock('librechat-data-provider', () => {
const actual = jest.requireActual('librechat-data-provider');
return {
...actual,
dataService: {
...actual.dataService,
getConversationById: (...args: unknown[]) => mockGetConversationById(...args),
},
};
});
jest.mock('~/data-provider', () => ({
useArchiveAllConversationsMutation: (options?: {
onSuccess?: () => void;
onError?: () => void | Promise<void>;
}) => {
mockOnSuccess = options?.onSuccess;
mockOnError = options?.onError;
return {
mutate: mockMutate,
isLoading: mockIsLoading,
};
},
}));
jest.mock('~/hooks/Conversations/useGetConversation', () => ({
__esModule: true,
default: () => mockGetConversation,
}));
jest.mock('~/hooks/Chat/useNewChat', () => ({
__esModule: true,
default: () => ({ startNewChat: mockStartNewChat }),
}));
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
}));
jest.mock('@tanstack/react-query', () => {
const actual = jest.requireActual('@tanstack/react-query');
return {
...actual,
useIsMutating: () => mockPendingArchives,
};
});
describe('ArchiveAllChats', () => {
beforeEach(() => {
mockIsLoading = false;
mockPendingArchives = 0;
mockOnSuccess = undefined;
mockOnError = undefined;
jest.clearAllMocks();
mockGetConversation.mockReturnValue({
conversationId: 'conversation-1',
isTemporary: false,
});
});
it('submits through the shared button and closes the confirmation dialog', async () => {
render(<ArchiveAllChats />);
fireEvent.click(screen.getByRole('button', { name: 'com_nav_archive_all_chats' }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'com_ui_archive' }));
expect(mockMutate).toHaveBeenCalledTimes(1);
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
});
it('disables the trigger and submit button while showing the loading spinner', () => {
const { rerender } = render(<ArchiveAllChats />);
const trigger = screen.getByRole('button', { name: 'com_nav_archive_all_chats' });
fireEvent.click(trigger);
mockIsLoading = true;
rerender(<ArchiveAllChats />);
const submit = screen.getByRole('button', { name: 'com_ui_archive' });
expect(trigger).toBeDisabled();
expect(submit).toBeDisabled();
expect(submit).toHaveAttribute('aria-busy', 'true');
expect(submit.querySelector('svg.spinner')).toBeInTheDocument();
});
it('stays disabled when another archive-all mutation is already pending', () => {
mockPendingArchives = 1;
render(<ArchiveAllChats />);
const trigger = screen.getByRole('button', { name: 'com_nav_archive_all_chats' });
expect(trigger).toBeDisabled();
fireEvent.click(trigger);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('starts a new chat when the archived conversation is still active', () => {
render(<ArchiveAllChats />);
fireEvent.click(screen.getByRole('button', { name: 'com_nav_archive_all_chats' }));
fireEvent.click(screen.getByRole('button', { name: 'com_ui_archive' }));
mockOnSuccess?.();
expect(mockStartNewChat).toHaveBeenCalledTimes(1);
});
it('keeps a conversation opened while the archive request was pending', () => {
render(<ArchiveAllChats />);
fireEvent.click(screen.getByRole('button', { name: 'com_nav_archive_all_chats' }));
fireEvent.click(screen.getByRole('button', { name: 'com_ui_archive' }));
mockGetConversation.mockReturnValue({
conversationId: 'conversation-2',
isTemporary: false,
});
mockOnSuccess?.();
expect(mockStartNewChat).not.toHaveBeenCalled();
});
it('keeps a temporary conversation that the backend did not archive', () => {
mockGetConversation.mockReturnValue({
conversationId: 'temporary-conversation',
isTemporary: true,
});
render(<ArchiveAllChats />);
fireEvent.click(screen.getByRole('button', { name: 'com_nav_archive_all_chats' }));
fireEvent.click(screen.getByRole('button', { name: 'com_ui_archive' }));
mockOnSuccess?.();
expect(mockStartNewChat).not.toHaveBeenCalled();
});
it('keeps a legacy temporary conversation that the backend did not archive', () => {
mockGetConversation.mockReturnValue({
conversationId: 'legacy-temporary-conversation',
expiredAt: '2026-08-16T12:00:00.000Z',
});
render(<ArchiveAllChats />);
fireEvent.click(screen.getByRole('button', { name: 'com_nav_archive_all_chats' }));
fireEvent.click(screen.getByRole('button', { name: 'com_ui_archive' }));
mockGetConversation.mockReturnValue({
conversationId: 'legacy-temporary-conversation',
isTemporary: false,
});
mockOnSuccess?.();
expect(mockStartNewChat).not.toHaveBeenCalled();
});
it('keeps an already-archived conversation that archive-all could not have changed', () => {
mockGetConversation.mockReturnValue({
conversationId: 'archived-conversation',
isArchived: true,
isTemporary: false,
});
render(<ArchiveAllChats />);
fireEvent.click(screen.getByRole('button', { name: 'com_nav_archive_all_chats' }));
fireEvent.click(screen.getByRole('button', { name: 'com_ui_archive' }));
mockOnSuccess?.();
expect(mockStartNewChat).not.toHaveBeenCalled();
});
it('keeps a new chat that has no persisted conversation id', () => {
mockGetConversation.mockReturnValue(null);
render(<ArchiveAllChats />);
fireEvent.click(screen.getByRole('button', { name: 'com_nav_archive_all_chats' }));
fireEvent.click(screen.getByRole('button', { name: 'com_ui_archive' }));
mockOnSuccess?.();
expect(mockStartNewChat).not.toHaveBeenCalled();
});
it('starts a new chat when a partial archive already committed the active conversation', async () => {
mockGetConversationById.mockResolvedValue({
conversationId: 'conversation-1',
isArchived: true,
});
render(<ArchiveAllChats />);
fireEvent.click(screen.getByRole('button', { name: 'com_nav_archive_all_chats' }));
fireEvent.click(screen.getByRole('button', { name: 'com_ui_archive' }));
await mockOnError?.();
expect(mockGetConversationById).toHaveBeenCalledWith('conversation-1');
expect(mockStartNewChat).toHaveBeenCalledTimes(1);
expect(mockShowToast).toHaveBeenCalledWith(
expect.objectContaining({ message: 'com_ui_archive_all_error' }),
);
});
it('keeps the active chat when a later archive batch fails before committing it', async () => {
mockGetConversationById.mockResolvedValue({
conversationId: 'conversation-1',
isArchived: false,
});
render(<ArchiveAllChats />);
fireEvent.click(screen.getByRole('button', { name: 'com_nav_archive_all_chats' }));
fireEvent.click(screen.getByRole('button', { name: 'com_ui_archive' }));
await mockOnError?.();
expect(mockGetConversationById).toHaveBeenCalledWith('conversation-1');
expect(mockStartNewChat).not.toHaveBeenCalled();
});
it('keeps the active chat when archive state cannot be confirmed after an error', async () => {
mockGetConversationById.mockRejectedValue(new Error('conversation lookup failed'));
render(<ArchiveAllChats />);
fireEvent.click(screen.getByRole('button', { name: 'com_nav_archive_all_chats' }));
fireEvent.click(screen.getByRole('button', { name: 'com_ui_archive' }));
await mockOnError?.();
expect(mockStartNewChat).not.toHaveBeenCalled();
expect(mockShowToast).toHaveBeenCalledWith(
expect.objectContaining({ message: 'com_ui_archive_all_error' }),
);
});
it('does not refetch when the user opened another conversation before the error', async () => {
render(<ArchiveAllChats />);
fireEvent.click(screen.getByRole('button', { name: 'com_nav_archive_all_chats' }));
fireEvent.click(screen.getByRole('button', { name: 'com_ui_archive' }));
mockGetConversation.mockReturnValue({
conversationId: 'conversation-2',
isTemporary: false,
});
await mockOnError?.();
expect(mockGetConversationById).not.toHaveBeenCalled();
expect(mockStartNewChat).not.toHaveBeenCalled();
});
it('keeps a conversation opened while the archive-state lookup is in flight', async () => {
let resolveLookup: (value: { conversationId: string; isArchived: boolean }) => void = () => {
throw new Error('lookup resolver was not captured');
};
mockGetConversationById.mockImplementation(
() =>
new Promise((resolve) => {
resolveLookup = resolve;
}),
);
render(<ArchiveAllChats />);
fireEvent.click(screen.getByRole('button', { name: 'com_nav_archive_all_chats' }));
fireEvent.click(screen.getByRole('button', { name: 'com_ui_archive' }));
const errorHandling = mockOnError?.();
mockGetConversation.mockReturnValue({
conversationId: 'conversation-2',
isTemporary: false,
});
resolveLookup({
conversationId: 'conversation-1',
isArchived: true,
});
await errorHandling;
expect(mockStartNewChat).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,212 @@
import React from 'react';
import { QueryKeys } from 'librechat-data-provider';
import { act, renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { useArchiveAllConversationsMutation } from '../mutations';
const mockArchiveAllConversations = jest.fn();
jest.mock('librechat-data-provider', () => {
const actual = jest.requireActual('librechat-data-provider');
return {
...actual,
dataService: {
...actual.dataService,
archiveAllConversations: (...args: unknown[]) => mockArchiveAllConversations(...args),
},
};
});
const createWrapper = (queryClient: QueryClient) =>
function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
describe('archive-all mutation cache refresh', () => {
beforeEach(() => {
mockArchiveAllConversations.mockReset();
});
it('refetches archived queries and removes inactive detail caches', async () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const archivedQuery = jest.fn().mockResolvedValue({ pages: [], pageParams: [] });
const projectKey = [QueryKeys.project, 'project-1'];
const conversationKey = [QueryKeys.conversation, 'conversation-1'];
await queryClient.fetchQuery(
[QueryKeys.archivedConversations, { isArchived: true }],
archivedQuery,
);
queryClient.setQueryData(projectKey, { _id: 'project-1', conversationCount: 1 });
queryClient.setQueryData(conversationKey, {
conversationId: 'conversation-1',
isArchived: false,
});
mockArchiveAllConversations.mockResolvedValue({ archivedCount: 1 });
const { result } = renderHook(() => useArchiveAllConversationsMutation(), {
wrapper: createWrapper(queryClient),
});
await act(async () => {
await result.current.mutateAsync();
});
await waitFor(() => {
expect(archivedQuery).toHaveBeenCalledTimes(2);
});
expect(queryClient.getQueryData(projectKey)).toBeUndefined();
expect(queryClient.getQueryData(conversationKey)).toBeUndefined();
queryClient.clear();
});
it('refetches active project detail queries', async () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const projectQuery = jest.fn().mockResolvedValue({ _id: 'project-1', conversationCount: 1 });
mockArchiveAllConversations.mockResolvedValue({ archivedCount: 1 });
const { result } = renderHook(
() => ({
archiveAll: useArchiveAllConversationsMutation(),
project: useQuery([QueryKeys.project, 'project-1'], projectQuery),
}),
{ wrapper: createWrapper(queryClient) },
);
await waitFor(() => {
expect(result.current.project.isSuccess).toBe(true);
expect(projectQuery).toHaveBeenCalledTimes(1);
});
await act(async () => {
await result.current.archiveAll.mutateAsync();
});
await waitFor(() => {
expect(projectQuery).toHaveBeenCalledTimes(2);
});
queryClient.clear();
});
it('refetches the pinned section so archived pins leave the sidebar', async () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: 5 * 60 * 1000 },
mutations: { retry: false },
},
});
const pinnedQuery = jest.fn().mockResolvedValue({ conversations: [], nextCursor: null });
mockArchiveAllConversations.mockResolvedValue({ archivedCount: 1 });
const { result } = renderHook(
() => ({
archiveAll: useArchiveAllConversationsMutation(),
pinned: useQuery([QueryKeys.pinnedConversations, { pinned: true }], pinnedQuery),
}),
{ wrapper: createWrapper(queryClient) },
);
await waitFor(() => {
expect(result.current.pinned.isSuccess).toBe(true);
expect(pinnedQuery).toHaveBeenCalledTimes(1);
});
await act(async () => {
await result.current.archiveAll.mutateAsync();
});
await waitFor(() => {
expect(pinnedQuery).toHaveBeenCalledTimes(2);
});
queryClient.clear();
});
it('refetches the pinned section when the request fails after a partial archive', async () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: 5 * 60 * 1000 },
mutations: { retry: false },
},
});
const pinnedQuery = jest.fn().mockResolvedValue({ conversations: [], nextCursor: null });
mockArchiveAllConversations.mockRejectedValue(new Error('later archive batch failed'));
const { result } = renderHook(
() => ({
archiveAll: useArchiveAllConversationsMutation(),
pinned: useQuery([QueryKeys.pinnedConversations, { pinned: true }], pinnedQuery),
}),
{ wrapper: createWrapper(queryClient) },
);
await waitFor(() => {
expect(result.current.pinned.isSuccess).toBe(true);
expect(pinnedQuery).toHaveBeenCalledTimes(1);
});
await act(async () => {
await expect(result.current.archiveAll.mutateAsync()).rejects.toThrow(
'later archive batch failed',
);
});
await waitFor(() => {
expect(pinnedQuery).toHaveBeenCalledTimes(2);
});
queryClient.clear();
});
it('reconciles caches when the request fails after a partial archive', async () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const archivedQuery = jest.fn().mockResolvedValue({ pages: [], pageParams: [] });
const projectKey = [QueryKeys.project, 'project-1'];
const conversationKey = [QueryKeys.conversation, 'conversation-1'];
await queryClient.fetchQuery(
[QueryKeys.archivedConversations, { isArchived: true }],
archivedQuery,
);
queryClient.setQueryData(projectKey, { _id: 'project-1', conversationCount: 1 });
queryClient.setQueryData(conversationKey, {
conversationId: 'conversation-1',
isArchived: false,
});
mockArchiveAllConversations.mockRejectedValue(new Error('later archive batch failed'));
const { result } = renderHook(() => useArchiveAllConversationsMutation(), {
wrapper: createWrapper(queryClient),
});
await act(async () => {
await expect(result.current.mutateAsync()).rejects.toThrow('later archive batch failed');
});
await waitFor(() => {
expect(archivedQuery).toHaveBeenCalledTimes(2);
});
expect(queryClient.getQueryData(projectKey)).toBeUndefined();
expect(queryClient.getQueryData(conversationKey)).toBeUndefined();
queryClient.clear();
});
});

View file

@ -15,6 +15,7 @@ import {
findConversationInInfinite,
updateConvoInAllQueries,
removeConvoFromAllQueries,
clearArchivedConversationMessagesCache,
clearDeletedConversationMessagesCache,
} from '~/utils';
import useUpdateTagsInConvo from '~/hooks/Conversations/useUpdateTagsInConvo';
@ -131,6 +132,9 @@ export const useArchiveConvoMutation = (
[QueryKeys.conversation, vars.conversationId],
isArchived ? null : _data,
);
if (isArchived) {
clearArchivedConversationMessagesCache(queryClient, vars.conversationId);
}
if (_data.chatProjectId) {
queryClient.invalidateQueries([QueryKeys.project, _data.chatProjectId]);
}
@ -158,6 +162,45 @@ export const useArchiveConvoMutation = (
);
};
export const useArchiveAllConversationsMutation = (
options?: t.ArchiveAllConversationsOptions,
): UseMutationResult<t.TArchiveAllConversationsResponse, unknown, void, unknown> => {
const queryClient = useQueryClient();
const { onSuccess, onError, ..._options } = options || {};
const reconcileCaches = () => {
queryClient.invalidateQueries([QueryKeys.allConversations]);
queryClient.invalidateQueries({
queryKey: [QueryKeys.archivedConversations],
refetchType: 'all',
});
/** The pinned section fetches on its own key with a five-minute stale time, so an
* archived pin would keep rendering in the sidebar without this. */
queryClient.invalidateQueries([QueryKeys.pinnedConversations]);
queryClient.invalidateQueries([QueryKeys.projectConversations]);
queryClient.invalidateQueries([QueryKeys.projects]);
queryClient.invalidateQueries([QueryKeys.project]);
queryClient.removeQueries([QueryKeys.project], { type: 'inactive' });
queryClient.removeQueries({ queryKey: [QueryKeys.conversation] });
};
return useMutation(
[MutationKeys.archiveAllConversations],
() => dataService.archiveAllConversations(),
{
onSuccess: (data, vars, context) => {
reconcileCaches();
onSuccess?.(data, vars, context);
},
onError: (error, vars, context) => {
reconcileCaches();
onError?.(error, vars, context);
},
..._options,
},
);
};
export const usePinConversationMutation = (
options?: t.PinConversationOptions,
): UseMutationResult<t.TPinConversationResponse, unknown, t.TPinConversationRequest, unknown> => {

View file

@ -437,6 +437,8 @@
"com_nav_advanced_prompts": "Advanced prompts editor",
"com_nav_advanced_prompts_desc": "Enable versioning and production control for prompts",
"com_nav_always_make_prod": "Always make new prompt versions production",
"com_nav_archive_all_chats": "Archive all chats",
"com_nav_archive_all_confirm_message": "Are you sure you want to archive all chats? They will be moved out of your chat list and remain available under Archived chats.",
"com_nav_archive_created_at": "Date Archived",
"com_nav_archive_name": "Name",
"com_nav_archived_chats": "Archived chats",
@ -485,6 +487,7 @@
"com_nav_clear_conversation_confirm_message": "Are you sure you want to clear all conversations? This is irreversible.",
"com_nav_client_image_resize": "Resize images before upload",
"com_nav_close_sidebar": "Close sidebar",
"com_nav_confirm_archive_all": "Confirm Archive",
"com_nav_confirm_clear": "Confirm Clear",
"com_nav_control_panel": "Control Panel",
"com_nav_conversation_mode": "Conversation Mode",
@ -879,6 +882,8 @@
"com_ui_approval_expired": "This request expired or was already handled.",
"com_ui_approve": "Approve",
"com_ui_archive": "Archive",
"com_ui_archive_all_error": "Failed to archive all chats",
"com_ui_archive_all_success": "All chats archived",
"com_ui_archive_delete_error": "Failed to delete archived conversation",
"com_ui_archive_error": "Failed to archive conversation",
"com_ui_artifact_click": "Click to open",
@ -1880,6 +1885,7 @@
"com_ui_set": "Set",
"com_ui_settings_label_2fa": "Two-factor authentication",
"com_ui_settings_label_agent_api_keys": "Agent API keys",
"com_ui_settings_label_archive_all_chats": "Archive all chats",
"com_ui_settings_label_auto_refill": "Auto-refill",
"com_ui_settings_label_avatar": "Avatar",
"com_ui_settings_label_backup_codes": "Backup codes",

View file

@ -5,6 +5,7 @@ import type { TEndpointsConfig } from 'librechat-data-provider';
import type { LocalizeFunction, TMessageProps } from '~/common';
import {
clearMessagesCache,
clearArchivedConversationMessagesCache,
clearDeletedConversationMessagesCache,
isValidTimestamp,
getMessageAriaLabel,
@ -107,6 +108,66 @@ describe('clearDeletedConversationMessagesCache', () => {
});
});
describe('clearArchivedConversationMessagesCache', () => {
it('clears the new-conversation cache that still shows the archived chat', () => {
const queryClient = new QueryClient();
const conversationId = 'conversation-1';
const messages = [makeMessage({ conversationId })];
queryClient.setQueryData([QueryKeys.messages, conversationId], messages);
queryClient.setQueryData(
[QueryKeys.messages, Constants.NEW_CONVO],
messages.map((message) => ({ ...message })),
);
clearArchivedConversationMessagesCache(queryClient, conversationId);
expect(queryClient.getQueryData([QueryKeys.messages, Constants.NEW_CONVO])).toEqual([]);
});
it('clears a shared new-conversation cache before its message IDs are hydrated', () => {
const queryClient = new QueryClient();
const conversationId = 'conversation-1';
const messages = [makeMessage({ conversationId: Constants.NEW_CONVO as string })];
queryClient.setQueryData([QueryKeys.messages, conversationId], messages);
queryClient.setQueryData([QueryKeys.messages, Constants.NEW_CONVO], messages);
clearArchivedConversationMessagesCache(queryClient, conversationId);
expect(queryClient.getQueryData([QueryKeys.messages, Constants.NEW_CONVO])).toEqual([]);
});
it('keeps the archived conversation history so reopening it from the archive is instant', () => {
const queryClient = new QueryClient();
const conversationId = 'conversation-1';
const messages = [makeMessage({ conversationId })];
queryClient.setQueryData([QueryKeys.messages, conversationId], messages);
queryClient.setQueryData([QueryKeys.messages, Constants.NEW_CONVO], messages);
clearArchivedConversationMessagesCache(queryClient, conversationId);
expect(queryClient.getQueryData([QueryKeys.messages, conversationId])).toEqual(messages);
});
it('preserves an unrelated new-conversation message cache', () => {
const queryClient = new QueryClient();
const conversationId = 'conversation-1';
const newConversationMessages = [
makeMessage({ messageId: 'new-message', conversationId: Constants.NEW_CONVO as string }),
];
queryClient.setQueryData(
[QueryKeys.messages, conversationId],
[makeMessage({ conversationId })],
);
queryClient.setQueryData([QueryKeys.messages, Constants.NEW_CONVO], newConversationMessages);
clearArchivedConversationMessagesCache(queryClient, conversationId);
expect(queryClient.getQueryData([QueryKeys.messages, Constants.NEW_CONVO])).toEqual(
newConversationMessages,
);
});
});
describe('getMessageAriaLabel', () => {
it('returns "Message N" when depth is present and valid', () => {
const msg = makeMessage({ depth: 2 });

View file

@ -321,12 +321,13 @@ export const clearMessagesCache = (
queryClient.setQueryData<TMessage[]>([QueryKeys.messages, Constants.NEW_CONVO], []);
};
/** Removes a deleted conversation's message cache and any matching new-chat cache alias. */
export const clearDeletedConversationMessagesCache = (
queryClient: QueryClient,
conversationId: string,
): void => {
const deletedMessages = queryClient.getQueryData<TMessage[]>([
/**
* True while the new-chat cache still holds the given conversation's messages: a chat's first
* turn writes the same array under both keys, so the alias survives until it is reset. The
* reference check covers the window before the messages carry their conversation ID.
*/
const newConversationCacheAliases = (queryClient: QueryClient, conversationId: string): boolean => {
const conversationMessages = queryClient.getQueryData<TMessage[]>([
QueryKeys.messages,
conversationId,
]);
@ -334,10 +335,20 @@ export const clearDeletedConversationMessagesCache = (
QueryKeys.messages,
Constants.NEW_CONVO,
]);
const newConversationAliasesDeleted =
return (
newConversationMessages != null &&
(newConversationMessages === deletedMessages ||
newConversationMessages.some((message) => message.conversationId === conversationId));
(newConversationMessages === conversationMessages ||
newConversationMessages.some((message) => message.conversationId === conversationId))
);
};
/** Removes a deleted conversation's message cache and any matching new-chat cache alias. */
export const clearDeletedConversationMessagesCache = (
queryClient: QueryClient,
conversationId: string,
): void => {
const newConversationAliasesDeleted = newConversationCacheAliases(queryClient, conversationId);
queryClient.removeQueries([QueryKeys.messages, conversationId], { exact: true });
@ -348,6 +359,22 @@ export const clearDeletedConversationMessagesCache = (
queryClient.setQueryData<TMessage[]>([QueryKeys.messages, Constants.NEW_CONVO], []);
};
/**
* Drops the new-chat alias of a conversation that was just archived, so returning to a new chat
* does not keep rendering it. Its own history stays cached: unlike a deleted chat, an archived
* one can still be reopened from the archive.
*/
export const clearArchivedConversationMessagesCache = (
queryClient: QueryClient,
conversationId: string,
): void => {
if (!newConversationCacheAliases(queryClient, conversationId)) {
return;
}
queryClient.setQueryData<TMessage[]>([QueryKeys.messages, Constants.NEW_CONVO], []);
};
/** Returns a 1-based message number, or null if depth is absent or invalid. */
const getMessageNumber = (message: TMessage): number | null => {
if (message.depth == null || message.depth < 0) {

View file

@ -0,0 +1,72 @@
import { logger } from '@librechat/data-schemas';
import type { Response } from 'express';
import type { ServerRequest } from '~/types';
import { createArchiveAllHandler } from './archive';
jest.mock('@librechat/data-schemas', () => ({
logger: {
error: jest.fn(),
},
}));
interface MockResponse {
statusCode: number;
body: { archivedCount: number } | string | undefined;
status: jest.Mock;
json: jest.Mock;
send: jest.Mock;
}
function mockRequest(): ServerRequest {
return {
user: { id: 'user-123' },
} as Partial<ServerRequest> as ServerRequest;
}
function mockResponse(): Response & MockResponse {
const res: MockResponse = {
statusCode: 200,
body: undefined,
status: jest.fn((statusCode: number) => {
res.statusCode = statusCode;
return res;
}),
json: jest.fn((body: MockResponse['body']) => {
res.body = body;
return res;
}),
send: jest.fn((body: MockResponse['body']) => {
res.body = body;
return res;
}),
};
return res as Partial<Response> as Response & MockResponse;
}
describe('createArchiveAllHandler', () => {
it('archives the authenticated user conversations and returns the result', async () => {
const archiveAllConvos = jest.fn().mockResolvedValue({ archivedCount: 4 });
const handler = createArchiveAllHandler({ archiveAllConvos });
const res = mockResponse();
await handler(mockRequest(), res);
expect(archiveAllConvos).toHaveBeenCalledWith('user-123');
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ archivedCount: 4 });
});
it('logs and returns 500 when archiving fails', async () => {
const error = new Error('Database error');
const archiveAllConvos = jest.fn().mockRejectedValue(error);
const handler = createArchiveAllHandler({ archiveAllConvos });
const res = mockResponse();
await handler(mockRequest(), res);
expect(logger.error).toHaveBeenCalledWith('Error archiving all conversations', error);
expect(res.statusCode).toBe(500);
expect(res.body).toBe('Error archiving all conversations');
});
});

View file

@ -0,0 +1,21 @@
import { logger } from '@librechat/data-schemas';
import type { ConversationMethods } from '@librechat/data-schemas';
import type { Response } from 'express';
import type { ServerRequest } from '~/types';
type ArchiveAllHandlerDependencies = Pick<ConversationMethods, 'archiveAllConvos'>;
export function createArchiveAllHandler(
deps: ArchiveAllHandlerDependencies,
): (req: ServerRequest, res: Response) => Promise<Response> {
return async function archiveAllHandler(req: ServerRequest, res: Response): Promise<Response> {
try {
const result = await deps.archiveAllConvos(req.user!.id);
return res.status(200).json(result);
} catch (error) {
logger.error('Error archiving all conversations', error);
return res.status(500).send('Error archiving all conversations');
}
};
}

View file

@ -0,0 +1 @@
export * from './archive';

View file

@ -55,6 +55,8 @@ export * from './actions';
export * from './prompts';
/* Projects */
export * from './projects';
/* Conversations */
export * from './conversations';
/* Skills */
export * from './skills';
export * from './favorites';

View file

@ -121,6 +121,7 @@ export const genTitle = (conversationId: string) =>
export const updateConversation = () => `${conversationsRoot}/update`;
export const archiveConversation = () => `${conversationsRoot}/archive`;
export const archiveAllConversations = () => `${conversationsRoot}/archive/all`;
export const pinConversation = () => `${conversationsRoot}/pin`;
export const deleteConversation = () => `${conversationsRoot}`;

View file

@ -892,6 +892,10 @@ export function archiveConversation(
return request.post(endpoints.archiveConversation(), { arg: payload });
}
export function archiveAllConversations(): Promise<t.TArchiveAllConversationsResponse> {
return request.post(endpoints.archiveAllConversations(), {});
}
export function listProjects(params?: q.ProjectListParams): Promise<q.ProjectListResponse> {
return request.get(endpoints.projects(params ?? {}));
}

View file

@ -134,4 +134,5 @@ export enum MutationKeys {
deleteSkillNode = 'deleteSkillNode',
updateSkillNodeContent = 'updateSkillNodeContent',
convoPin = 'convoPin',
archiveAllConversations = 'archiveAllConversations',
}

View file

@ -419,6 +419,10 @@ export type TArchiveConversationRequest = {
export type TArchiveConversationResponse = TConversation;
export type TArchiveAllConversationsResponse = {
archivedCount: number;
};
export type TPinConversationRequest = {
conversationId: string;
pinned: boolean;

View file

@ -232,6 +232,11 @@ export type ArchiveConvoOptions = MutationOptions<
types.TArchiveConversationRequest
>;
export type ArchiveAllConversationsOptions = MutationOptions<
types.TArchiveAllConversationsResponse,
void
>;
export type DeleteSharedLinkContext = { previousQueries?: Map<string, TDeleteSharedLinkResponse> };
export type DeleteSharedLinkOptions = MutationOptions<
TDeleteSharedLinkResponse,

View file

@ -1,7 +1,11 @@
import mongoose from 'mongoose';
import { MongoMemoryServer } from 'mongodb-memory-server';
import type { IChatProject, IConversation } from '~/types';
import { createChatProjectMethods, type ChatProjectMethods } from './chatProject';
import {
createChatProjectMethods,
updateChatProjectLastConversationForUser,
type ChatProjectMethods,
} from './chatProject';
import { createModels } from '~/models';
jest.mock('~/config/winston', () => ({
@ -257,6 +261,181 @@ describe('ChatProject methods', () => {
expect(refreshedProject?.lastConversationAt?.toISOString()).toBe(visibleDate.toISOString());
});
it('retries instead of overwriting a newer concurrent stats update', async () => {
const project = await methods.createChatProject(user, { name: 'Concurrent Stats' });
const chatProjectId = project._id!.toString();
const initialDate = new Date('2026-01-01T00:00:00.000Z');
const newerDate = new Date('2026-02-01T00:00:00.000Z');
await Conversation.collection.insertOne({
conversationId: 'initial-convo',
title: 'Initial',
user,
endpoint: 'openAI',
chatProjectId,
createdAt: initialDate,
updatedAt: initialDate,
});
await ChatProject.findByIdAndUpdate(project._id, {
conversationCount: 1,
lastConversationAt: initialDate,
lastConversationId: 'initial-convo',
});
const findOneAndUpdate = ChatProject.findOneAndUpdate.bind(ChatProject);
const updateSpy = jest
.spyOn(ChatProject, 'findOneAndUpdate')
.mockImplementationOnce((filter, update, options) => {
const query = findOneAndUpdate(filter, update, options);
const exec = query.exec.bind(query);
jest.spyOn(query, 'exec').mockImplementationOnce(async () => {
await Conversation.collection.insertOne({
conversationId: 'newer-convo',
title: 'Newer',
user,
endpoint: 'openAI',
chatProjectId,
createdAt: newerDate,
updatedAt: newerDate,
});
await ChatProject.updateOne(
{ _id: project._id },
{
conversationCount: 2,
lastConversationAt: newerDate,
lastConversationId: 'newer-convo',
},
);
return await exec();
});
return query;
});
try {
const refreshed = await methods.refreshChatProjectStats(user, chatProjectId);
expect(refreshed?.conversationCount).toBe(2);
expect(refreshed?.lastConversationId).toBe('newer-convo');
expect(refreshed?.lastConversationAt?.toISOString()).toBe(newerDate.toISOString());
expect(updateSpy).toHaveBeenCalledTimes(2);
} finally {
updateSpy.mockRestore();
}
const persisted = await ChatProject.findById(project._id).lean<IChatProject>();
expect(persisted?.conversationCount).toBe(2);
expect(persisted?.lastConversationId).toBe('newer-convo');
expect(persisted?.lastConversationAt?.toISOString()).toBe(newerDate.toISOString());
});
it('keeps reconciling after the first three optimistic attempts lose the race', async () => {
const project = await methods.createChatProject(user, { name: 'Exhausted Then Succeeds' });
const chatProjectId = project._id!.toString();
await ChatProject.findByIdAndUpdate(project._id, {
conversationCount: 4,
lastConversationId: 'stale-convo',
});
let casAttempts = 0;
const findOneAndUpdate = ChatProject.findOneAndUpdate.bind(ChatProject);
const updateSpy = jest
.spyOn(ChatProject, 'findOneAndUpdate')
.mockImplementation((filter, update, options) => {
const query = findOneAndUpdate(filter, update, options);
const exec = query.exec.bind(query);
jest.spyOn(query, 'exec').mockImplementation(async () => {
casAttempts += 1;
if (casAttempts <= 3) {
await ChatProject.updateOne(
{ _id: project._id },
{
lastConversationAt: new Date(`2026-03-0${casAttempts}T00:00:00.000Z`),
lastConversationId: `concurrent-${casAttempts}`,
},
);
}
return await exec();
});
return query;
});
try {
const refreshed = await methods.refreshChatProjectStats(user, chatProjectId);
expect(refreshed?.conversationCount).toBe(0);
expect(refreshed?.lastConversationId).toBeNull();
expect(casAttempts).toBeGreaterThan(3);
} finally {
updateSpy.mockRestore();
}
const persisted = await ChatProject.findById(project._id).lean<IChatProject>();
expect(persisted?.conversationCount).toBe(0);
expect(persisted?.lastConversationId).toBeNull();
});
it('throws instead of returning stale stats when every optimistic write loses', async () => {
const project = await methods.createChatProject(user, { name: 'Never Settles' });
const chatProjectId = project._id!.toString();
await ChatProject.findByIdAndUpdate(project._id, {
conversationCount: 4,
lastConversationId: 'stale-convo',
});
const updateSpy = jest.spyOn(ChatProject, 'findOneAndUpdate').mockImplementation(
() =>
({
lean: async () => null,
}) as unknown as ReturnType<typeof ChatProject.findOneAndUpdate>,
);
try {
await expect(methods.refreshChatProjectStats(user, chatProjectId)).rejects.toThrow(
/refresh chat project stats/i,
);
} finally {
updateSpy.mockRestore();
}
const persisted = await ChatProject.findById(project._id).lean<IChatProject>();
expect(persisted?.conversationCount).toBe(4);
expect(persisted?.lastConversationId).toBe('stale-convo');
});
it('does not increment again when a refresh already counted the new conversation', async () => {
const project = await methods.createChatProject(user, { name: 'Pending Increment' });
const chatProjectId = project._id!.toString();
const createdAt = new Date('2026-04-01T00:00:00.000Z');
await Conversation.create({
conversationId: 'new-convo',
title: 'New',
user,
endpoint: 'openAI',
chatProjectId,
createdAt,
updatedAt: createdAt,
});
const refreshed = await methods.refreshChatProjectStats(user, chatProjectId);
expect(refreshed?.conversationCount).toBe(1);
expect(refreshed?.lastConversationId).toBe('new-convo');
await updateChatProjectLastConversationForUser(
mongoose,
user,
chatProjectId,
{
conversationId: 'new-convo',
createdAt,
updatedAt: createdAt,
},
true,
);
const persisted = await ChatProject.findById(project._id).lean<IChatProject>();
expect(persisted?.conversationCount).toBe(1);
expect(persisted?.lastConversationId).toBe('new-convo');
});
it('enforces one project per chat when moving conversations', async () => {
const firstProject = await methods.createChatProject(user, { name: 'First' });
const secondProject = await methods.createChatProject(user, { name: 'Second' });

View file

@ -70,8 +70,13 @@ type ProjectCursor = {
};
type ProjectLean = IChatProject & { _id: Types.ObjectId };
type ProjectStatsSnapshot = Pick<
IChatProject,
'conversationCount' | 'lastConversationAt' | 'lastConversationId'
>;
const VALID_SORT_FIELDS = new Set<ChatProjectSortBy>(['name', 'createdAt', 'lastConversationAt']);
const PROJECT_STATS_REFRESH_MAX_ATTEMPTS = 8;
function normalizeSortBy(sortBy?: string): ChatProjectSortBy {
return VALID_SORT_FIELDS.has(sortBy as ChatProjectSortBy)
@ -194,6 +199,19 @@ function visibleProjectConversationFilter(
} as FilterQuery<IConversation>;
}
function projectStatsSnapshotFilter(
snapshot: ProjectStatsSnapshot,
): FilterQuery<IChatProjectDocument> {
return {
conversationCount:
snapshot.conversationCount === undefined ? { $exists: false } : snapshot.conversationCount,
lastConversationAt:
snapshot.lastConversationAt === undefined ? { $exists: false } : snapshot.lastConversationAt,
lastConversationId:
snapshot.lastConversationId === undefined ? { $exists: false } : snapshot.lastConversationId,
};
}
export async function refreshChatProjectStatsForUser(
mongoose: typeof import('mongoose'),
user: string,
@ -208,25 +226,44 @@ export async function refreshChatProjectStatsForUser(
const projectFilter = { _id: new mongoose.Types.ObjectId(projectId), user };
const conversationFilter = visibleProjectConversationFilter(user, projectId);
const [conversationCount, latestConversation] = await Promise.all([
Conversation.countDocuments(conversationFilter),
Conversation.findOne(conversationFilter)
.select('conversationId updatedAt createdAt')
.sort({ updatedAt: -1, _id: -1 })
.lean<IConversation>(),
]);
for (let attempt = 0; attempt < PROJECT_STATS_REFRESH_MAX_ATTEMPTS; attempt++) {
const snapshot = await ChatProject.findOne(projectFilter)
.select('conversationCount lastConversationAt lastConversationId')
.lean<ProjectStatsSnapshot>();
if (!snapshot) {
return null;
}
return await ChatProject.findOneAndUpdate(
projectFilter,
{
$set: {
conversationCount,
lastConversationAt: latestConversation?.updatedAt ?? latestConversation?.createdAt ?? null,
lastConversationId: latestConversation?.conversationId ?? null,
const [conversationCount, latestConversation] = await Promise.all([
Conversation.countDocuments(conversationFilter),
Conversation.findOne(conversationFilter)
.select('conversationId updatedAt createdAt')
.sort({ updatedAt: -1, _id: -1 })
.lean<IConversation>(),
]);
const updatedProject = await ChatProject.findOneAndUpdate(
{ ...projectFilter, ...projectStatsSnapshotFilter(snapshot) },
{
$set: {
conversationCount,
lastConversationAt:
latestConversation?.updatedAt ?? latestConversation?.createdAt ?? null,
lastConversationId: latestConversation?.conversationId ?? null,
},
},
},
{ new: true },
).lean<IChatProject>();
{ new: true },
).lean<IChatProject>();
if (updatedProject) {
return updatedProject;
}
}
logger.warn('[refreshChatProjectStatsForUser] Stats changed during every refresh attempt', {
user,
projectId,
});
throw new Error('Failed to refresh chat project stats after concurrent updates');
}
export async function updateChatProjectLastConversationForUser(
@ -241,18 +278,49 @@ export async function updateChatProjectLastConversationForUser(
}
const lastConversationAt = conversation.updatedAt ?? conversation.createdAt ?? new Date();
const update: Record<string, unknown> = {
$set: {
lastConversationAt,
lastConversationId: conversation.conversationId,
},
const lastConversationFields = {
lastConversationAt,
lastConversationId: conversation.conversationId,
};
if (incrementCount) {
update.$inc = { conversationCount: 1 };
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
const projectFilter = { _id: new mongoose.Types.ObjectId(projectId), user };
if (!incrementCount) {
await ChatProject.updateOne(projectFilter, { $set: lastConversationFields });
} else {
/**
* Skip `$inc` when a concurrent refresh already recorded this conversation as
* `lastConversationId`. The conversation is visible to countDocuments as soon
* as it is persisted, so a later increment would double-count it.
*/
const incremented = await ChatProject.updateOne(
{ ...projectFilter, lastConversationId: { $ne: conversation.conversationId } },
{ $set: lastConversationFields, $inc: { conversationCount: 1 } },
);
if ((incremented.matchedCount ?? 0) === 0) {
await ChatProject.updateOne(projectFilter, { $set: lastConversationFields });
}
}
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
await ChatProject.updateOne({ _id: new mongoose.Types.ObjectId(projectId), user }, update);
/**
* The chat can stop being visible while this pointer write is in flight: an archive-all
* sweep, a single archive from another tab, or a retention flip, each of which has
* already run its own stats refresh by the time this lands, leaving the project
* advertising activity on a chat its workspace hides. Checking first would only move
* that race earlier, so the pointer is verified after it is written and the project
* recomputed when the chat it names turns out to be hidden. A sweep that lands later
* still refreshes the project itself, and `refreshChatProjectStatsForUser` compare-and-
* sets, so it cannot commit a count it read before this write. The read is a point
* lookup on the unique `conversationId, user` index.
*/
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const stillVisible = await Conversation.exists({
...visibleProjectConversationFilter(user, projectId),
conversationId: conversation.conversationId,
});
if (!stillVisible) {
await refreshChatProjectStatsForUser(mongoose, user, projectId);
}
}
export function createChatProjectMethods(mongoose: typeof import('mongoose')): ChatProjectMethods {

View file

@ -2,7 +2,13 @@ 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 { Document, Filter, FindOneAndUpdateOptions, UpdateFilter } from 'mongodb';
import type {
Document,
Filter,
FindOneAndUpdateOptions,
UpdateFilter,
UpdateResult,
} from 'mongodb';
import type { IChatProject, IConversation } from '../types';
import { ConversationMethods, createConversationMethods } from './conversation';
import { tenantStorage, runAsSystem } from '~/config/tenantContext';
@ -83,6 +89,8 @@ const getConvoFiles = (...args: Parameters<ConversationMethods['getConvoFiles']>
methods.getConvoFiles(...args);
const deleteConvos = (...args: Parameters<ConversationMethods['deleteConvos']>) =>
methods.deleteConvos(...args);
const archiveAllConvos = (...args: Parameters<ConversationMethods['archiveAllConvos']>) =>
methods.archiveAllConvos(...args);
const getConvosByCursor = (...args: Parameters<ConversationMethods['getConvosByCursor']>) =>
methods.getConvosByCursor(...args);
const getConvosQueried = (...args: Parameters<ConversationMethods['getConvosQueried']>) =>
@ -93,6 +101,15 @@ const deleteNullOrEmptyConversations = (
const searchConversation = (...args: Parameters<ConversationMethods['searchConversation']>) =>
methods.searchConversation(...args);
/** The archive sweep discovers projects either by a bounded id list or by its own
* `archivedAt` marker, and the tests below assert which of the two a call used. */
type ArchiveDistinctFilter = {
_id?: { $in?: unknown[] };
user?: string;
isArchived?: boolean;
archivedAt?: Date;
};
describe('Conversation Operations', () => {
let mockCtx: {
userId: string;
@ -1724,6 +1741,729 @@ describe('Conversation Operations', () => {
});
});
describe('archiveAllConvos', () => {
it('defines an index for the user-scoped ObjectId batch scan', () => {
const indexFields = Conversation.schema.indexes().map(([fields]) => fields);
expect(indexFields).toContainEqual({ user: 1, _id: 1 });
});
it('archives every unarchived conversation for the user only', async () => {
const first = uuidv4();
const second = uuidv4();
const otherUsers = uuidv4();
await Conversation.create([
{ conversationId: first, user: 'user123', endpoint: EModelEndpoint.openAI },
{ conversationId: second, user: 'user123', endpoint: EModelEndpoint.openAI },
{ conversationId: otherUsers, user: 'user456', endpoint: EModelEndpoint.openAI },
]);
const result = await archiveAllConvos('user123');
expect(result.archivedCount).toBe(2);
const archived = await Conversation.find({ user: 'user123' }).lean<IConversation[]>();
expect(archived.every((convo) => convo.isArchived === true)).toBe(true);
const untouched = await Conversation.findOne({
conversationId: otherUsers,
}).lean<IConversation>();
expect(untouched?.isArchived).not.toBe(true);
});
it('archives large snapshots in bounded batches', async () => {
const conversations = Array.from({ length: 501 }, (_, index) => ({
conversationId: `archive-batch-${index}`,
user: 'user123',
endpoint: EModelEndpoint.openAI,
}));
await Conversation.insertMany(conversations);
const updateManySpy = jest.spyOn(Conversation, 'updateMany');
try {
const result = await archiveAllConvos('user123');
expect(result.archivedCount).toBe(conversations.length);
expect(updateManySpy).toHaveBeenCalledTimes(2);
} finally {
updateManySpy.mockRestore();
}
const unarchivedCount = await Conversation.countDocuments({
user: 'user123',
isArchived: { $ne: true },
});
expect(unarchivedCount).toBe(0);
});
it('recovers destination projects from the sweep marker, not a per-conversation id list', async () => {
const conversations = Array.from({ length: 501 }, (_, index) => ({
conversationId: `archive-recovery-batch-${index}`,
user: 'user123',
endpoint: EModelEndpoint.openAI,
}));
await Conversation.insertMany(conversations);
const distinct = Conversation.distinct.bind(Conversation);
const distinctFilters: ArchiveDistinctFilter[] = [];
const distinctSpy = jest.spyOn(Conversation, 'distinct').mockImplementation(((
field,
filter,
) => {
distinctFilters.push((filter ?? {}) as ArchiveDistinctFilter);
return distinct(field, filter);
}) as typeof Conversation.distinct);
try {
const result = await archiveAllConvos('user123');
expect(result.archivedCount).toBe(conversations.length);
} finally {
distinctSpy.mockRestore();
}
/** Every id list stays within one batch, so nothing accumulates across the sweep. */
const idListSizes = distinctFilters
.map((filter) => filter._id?.$in?.length)
.filter((size): size is number => size !== undefined);
expect(idListSizes.length).toBeGreaterThan(0);
expect(Math.max(...idListSizes)).toBeLessThanOrEqual(500);
const recoveryFilters = distinctFilters.filter((filter) => filter.archivedAt instanceof Date);
expect(recoveryFilters).toHaveLength(1);
expect(recoveryFilters[0]._id).toBeUndefined();
expect(recoveryFilters[0]).toMatchObject({ user: 'user123', isArchived: true });
});
it('recovers a batch-sized destination move from the marker alone', async () => {
const destinationProject = await ChatProject.create({ user: 'user123', name: 'Destination' });
const conversations = Array.from({ length: 501 }, (_, index) => ({
conversationId: `archive-recovery-scale-${index}`,
user: 'user123',
endpoint: EModelEndpoint.openAI,
}));
await Conversation.insertMany(conversations);
/** The chats carry no project when the sweep captures them, so the destination is
* reachable only through the marker: nothing in the loop ever saw it. */
const updateMany = Conversation.updateMany.bind(Conversation);
const updateManySpy = jest.spyOn(Conversation, 'updateMany').mockImplementationOnce((async (
filter,
update,
options,
) => {
const result = await updateMany(filter, update, options);
await Conversation.updateMany(
{ user: 'user123' },
{ $set: { chatProjectId: destinationProject._id!.toString() } },
{ timestamps: false },
);
await ChatProject.findByIdAndUpdate(destinationProject._id, {
conversationCount: conversations.length,
lastConversationId: conversations[conversations.length - 1].conversationId,
});
return result;
}) as typeof Conversation.updateMany);
const distinct = Conversation.distinct.bind(Conversation);
const distinctSpy = jest
.spyOn(Conversation, 'distinct')
.mockRejectedValueOnce(new Error('project discovery failed'))
.mockRejectedValueOnce(new Error('project discovery failed'))
.mockRejectedValueOnce(new Error('project discovery failed'))
.mockImplementation(((...args) => distinct(...args)) as typeof Conversation.distinct);
try {
await expect(archiveAllConvos('user123')).rejects.toThrow('project discovery failed');
} finally {
updateManySpy.mockRestore();
distinctSpy.mockRestore();
}
const archived = await Conversation.countDocuments({ user: 'user123', isArchived: true });
expect(archived).toBe(500);
const refreshed = await ChatProject.findById(destinationProject._id).lean<IChatProject>();
expect(refreshed?.conversationCount).toBe(1);
});
it('replays a failed project refresh without abandoning the rest of the run', async () => {
const projects = await ChatProject.insertMany(
Array.from({ length: 11 }, (_, index) => ({
user: 'user123',
name: `Project ${index}`,
conversationCount: 1,
lastConversationId: `project-conversation-${index}`,
})),
);
await Conversation.insertMany(
projects.map((project, index) => ({
conversationId: `project-conversation-${index}`,
user: 'user123',
endpoint: EModelEndpoint.openAI,
chatProjectId: project._id!.toString(),
})),
);
const countDocumentsSpy = jest
.spyOn(Conversation, 'countDocuments')
.mockRejectedValueOnce(new Error('transient project refresh failure'));
try {
const result = await archiveAllConvos('user123');
expect(result.archivedCount).toBe(projects.length);
/** Ten in the first batch and one in the second, then a replay of the one that
* failed: nothing else in the run is still competing with it by then. */
expect(countDocumentsSpy).toHaveBeenCalledTimes(projects.length + 1);
} finally {
countDocumentsSpy.mockRestore();
}
const refreshed = await ChatProject.find({ user: 'user123' }).lean<IChatProject[]>();
expect(refreshed).toHaveLength(projects.length);
for (const project of refreshed) {
expect(project.conversationCount).toBe(0);
expect(project.lastConversationId).toBeNull();
}
});
it('leaves already-archived conversations untouched', async () => {
const alreadyArchived = uuidv4();
await Conversation.create({
conversationId: alreadyArchived,
user: 'user123',
endpoint: EModelEndpoint.openAI,
isArchived: true,
});
const result = await archiveAllConvos('user123');
expect(result.archivedCount).toBe(0);
});
it('skips temporary and retention-expired conversations', async () => {
const temporary = uuidv4();
const expired = uuidv4();
const visible = uuidv4();
await Conversation.create([
{
conversationId: temporary,
user: 'user123',
endpoint: EModelEndpoint.openAI,
isTemporary: true,
expiredAt: new Date(Date.now() + 60 * 60 * 1000),
},
{
conversationId: expired,
user: 'user123',
endpoint: EModelEndpoint.openAI,
expiredAt: new Date(Date.now() - 60 * 60 * 1000),
},
{ conversationId: visible, user: 'user123', endpoint: EModelEndpoint.openAI },
]);
const result = await archiveAllConvos('user123');
expect(result.archivedCount).toBe(1);
const temporaryConvo = await Conversation.findOne({
conversationId: temporary,
}).lean<IConversation>();
const expiredConvo = await Conversation.findOne({
conversationId: expired,
}).lean<IConversation>();
expect(temporaryConvo?.isArchived).not.toBe(true);
expect(expiredConvo?.isArchived).not.toBe(true);
});
it('preserves each conversation updatedAt so the archived view keeps its order', async () => {
const convoId = uuidv4();
const updatedAt = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
await Conversation.create({
conversationId: convoId,
user: 'user123',
endpoint: EModelEndpoint.openAI,
createdAt: updatedAt,
updatedAt,
});
await archiveAllConvos('user123');
const archived = await Conversation.findOne({
conversationId: convoId,
}).lean<IConversation>();
expect(archived?.updatedAt?.toISOString()).toBe(updatedAt.toISOString());
});
it('stamps archivedAt so the sweep is dated and sorted as archived now', async () => {
const before = Date.now();
const convoIds = [uuidv4(), uuidv4()];
await Conversation.insertMany(
convoIds.map((conversationId) => ({
conversationId,
user: 'user123',
endpoint: EModelEndpoint.openAI,
createdAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
})),
);
await archiveAllConvos('user123');
const archived = await Conversation.find({ conversationId: { $in: convoIds } }).lean<
IConversation[]
>();
expect(archived).toHaveLength(2);
for (const conversation of archived) {
expect(conversation.archivedAt).toBeInstanceOf(Date);
expect(new Date(conversation.archivedAt ?? 0).getTime()).toBeGreaterThanOrEqual(before);
}
expect(new Date(archived[0].archivedAt ?? 0).toISOString()).toBe(
new Date(archived[1].archivedAt ?? 0).toISOString(),
);
});
it('leaves the archivedAt of an already-archived chat alone', async () => {
const original = new Date('2026-01-01T00:00:00.000Z');
const archivedId = uuidv4();
await Conversation.create({
conversationId: archivedId,
user: 'user123',
endpoint: EModelEndpoint.openAI,
isArchived: true,
archivedAt: original,
});
await archiveAllConvos('user123');
const untouched = await Conversation.findOne({
conversationId: archivedId,
}).lean<IConversation>();
expect(new Date(untouched?.archivedAt ?? 0).toISOString()).toBe(original.toISOString());
});
it('refreshes stats for every project the archived conversations belonged to', async () => {
const project = await ChatProject.create({ user: 'user123', name: 'Project' });
const projectId = project._id!.toString();
const convoId = uuidv4();
await Conversation.create({
conversationId: convoId,
user: 'user123',
endpoint: EModelEndpoint.openAI,
chatProjectId: projectId,
});
await ChatProject.findByIdAndUpdate(project._id, {
conversationCount: 1,
lastConversationId: convoId,
});
await archiveAllConvos('user123');
const refreshed = await ChatProject.findById(project._id).lean<IChatProject>();
expect(refreshed?.conversationCount).toBe(0);
expect(refreshed?.lastConversationId).toBeNull();
});
it('does not archive a project conversation created after the initial snapshot', async () => {
const project = await ChatProject.create({ user: 'user123', name: 'Project' });
const projectId = project._id!.toString();
const initialConversationId = uuidv4();
const concurrentConversationId = uuidv4();
await Conversation.create({
conversationId: initialConversationId,
user: 'user123',
endpoint: EModelEndpoint.openAI,
});
const updateMany = Conversation.updateMany.bind(Conversation);
const updateManySpy = jest.spyOn(Conversation, 'updateMany').mockImplementationOnce((async (
filter,
update,
options,
) => {
await Conversation.create({
conversationId: concurrentConversationId,
user: 'user123',
endpoint: EModelEndpoint.openAI,
chatProjectId: projectId,
});
await ChatProject.findByIdAndUpdate(project._id, {
conversationCount: 1,
lastConversationId: concurrentConversationId,
});
return await updateMany(filter, update, options);
}) as typeof Conversation.updateMany);
try {
const result = await archiveAllConvos('user123');
expect(result.archivedCount).toBe(1);
} finally {
updateManySpy.mockRestore();
}
const concurrentConversation = await Conversation.findOne({
conversationId: concurrentConversationId,
}).lean<IConversation>();
const refreshed = await ChatProject.findById(project._id).lean<IChatProject>();
expect(concurrentConversation?.isArchived).not.toBe(true);
expect(refreshed?.conversationCount).toBe(1);
expect(refreshed?.lastConversationId).toBe(concurrentConversationId);
});
it('retries destination-project discovery after a transient distinct failure', async () => {
const sourceProject = await ChatProject.create({ user: 'user123', name: 'Source' });
const destinationProject = await ChatProject.create({ user: 'user123', name: 'Destination' });
const conversationId = uuidv4();
await Conversation.create({
conversationId,
user: 'user123',
endpoint: EModelEndpoint.openAI,
chatProjectId: sourceProject._id!.toString(),
});
await ChatProject.findByIdAndUpdate(sourceProject._id, {
conversationCount: 1,
lastConversationId: conversationId,
});
const updateMany = Conversation.updateMany.bind(Conversation);
const updateManySpy = jest.spyOn(Conversation, 'updateMany').mockImplementationOnce((async (
filter,
update,
options,
) => {
await Conversation.updateOne(
{ conversationId },
{ $set: { chatProjectId: destinationProject._id!.toString() } },
);
await ChatProject.findByIdAndUpdate(sourceProject._id, {
conversationCount: 0,
lastConversationId: null,
});
await ChatProject.findByIdAndUpdate(destinationProject._id, {
conversationCount: 1,
lastConversationId: conversationId,
});
return await updateMany(filter, update, options);
}) as typeof Conversation.updateMany);
const distinct = Conversation.distinct.bind(Conversation);
const distinctSpy = jest
.spyOn(Conversation, 'distinct')
.mockRejectedValueOnce(new Error('transient project discovery failure'))
.mockImplementation(((...args) => distinct(...args)) as typeof Conversation.distinct);
try {
const result = await archiveAllConvos('user123');
expect(result.archivedCount).toBe(1);
} finally {
updateManySpy.mockRestore();
distinctSpy.mockRestore();
}
const refreshed = await ChatProject.findById(destinationProject._id).lean<IChatProject>();
expect(refreshed?.conversationCount).toBe(0);
expect(refreshed?.lastConversationId).toBeNull();
});
it('reconciles the destination project when discovery retries exhaust after a move', async () => {
const sourceProject = await ChatProject.create({ user: 'user123', name: 'Source' });
const destinationProject = await ChatProject.create({ user: 'user123', name: 'Destination' });
const conversationId = uuidv4();
await Conversation.create({
conversationId,
user: 'user123',
endpoint: EModelEndpoint.openAI,
chatProjectId: sourceProject._id!.toString(),
});
await ChatProject.findByIdAndUpdate(sourceProject._id, {
conversationCount: 1,
lastConversationId: conversationId,
});
const updateMany = Conversation.updateMany.bind(Conversation);
const updateManySpy = jest.spyOn(Conversation, 'updateMany').mockImplementationOnce((async (
filter,
update,
options,
) => {
await Conversation.updateOne(
{ conversationId },
{ $set: { chatProjectId: destinationProject._id!.toString() } },
);
await ChatProject.findByIdAndUpdate(sourceProject._id, {
conversationCount: 0,
lastConversationId: null,
});
await ChatProject.findByIdAndUpdate(destinationProject._id, {
conversationCount: 1,
lastConversationId: conversationId,
});
return await updateMany(filter, update, options);
}) as typeof Conversation.updateMany);
const distinct = Conversation.distinct.bind(Conversation);
const distinctSpy = jest
.spyOn(Conversation, 'distinct')
.mockRejectedValueOnce(new Error('project discovery failed'))
.mockRejectedValueOnce(new Error('project discovery failed'))
.mockRejectedValueOnce(new Error('project discovery failed'))
.mockImplementation(((...args) => distinct(...args)) as typeof Conversation.distinct);
try {
await expect(archiveAllConvos('user123')).rejects.toThrow('project discovery failed');
} finally {
updateManySpy.mockRestore();
distinctSpy.mockRestore();
}
const archived = await Conversation.findOne({ conversationId }).lean<IConversation>();
const refreshed = await ChatProject.findById(destinationProject._id).lean<IChatProject>();
expect(archived?.isArchived).toBe(true);
expect(refreshed?.conversationCount).toBe(0);
expect(refreshed?.lastConversationId).toBeNull();
});
it('refreshes the destination project when a captured conversation moves before archiving', async () => {
const sourceProject = await ChatProject.create({ user: 'user123', name: 'Source' });
const destinationProject = await ChatProject.create({ user: 'user123', name: 'Destination' });
const conversationId = uuidv4();
await Conversation.create({
conversationId,
user: 'user123',
endpoint: EModelEndpoint.openAI,
chatProjectId: sourceProject._id!.toString(),
});
await ChatProject.findByIdAndUpdate(sourceProject._id, {
conversationCount: 1,
lastConversationId: conversationId,
});
const updateMany = Conversation.updateMany.bind(Conversation);
const updateManySpy = jest.spyOn(Conversation, 'updateMany').mockImplementationOnce((async (
filter,
update,
options,
) => {
await Conversation.updateOne(
{ conversationId },
{ $set: { chatProjectId: destinationProject._id!.toString() } },
);
await ChatProject.findByIdAndUpdate(sourceProject._id, {
conversationCount: 0,
lastConversationId: null,
});
await ChatProject.findByIdAndUpdate(destinationProject._id, {
conversationCount: 1,
lastConversationId: conversationId,
});
return await updateMany(filter, update, options);
}) as typeof Conversation.updateMany);
try {
const result = await archiveAllConvos('user123');
expect(result.archivedCount).toBe(1);
} finally {
updateManySpy.mockRestore();
}
const refreshed = await ChatProject.findById(destinationProject._id).lean<IChatProject>();
expect(refreshed?.conversationCount).toBe(0);
expect(refreshed?.lastConversationId).toBeNull();
});
it('refreshes project stats for committed batches when a later batch fails', async () => {
const project = await ChatProject.create({ user: 'user123', name: 'Project' });
const projectId = project._id!.toString();
const conversations = Array.from({ length: 501 }, (_, index) => ({
conversationId: `archive-partial-fail-${index}`,
user: 'user123',
endpoint: EModelEndpoint.openAI,
chatProjectId: projectId,
}));
await Conversation.insertMany(conversations);
await ChatProject.findByIdAndUpdate(project._id, {
conversationCount: conversations.length,
lastConversationId: conversations[conversations.length - 1].conversationId,
});
const updateMany = Conversation.updateMany.bind(Conversation);
const updateManySpy = jest
.spyOn(Conversation, 'updateMany')
.mockImplementationOnce(((...args) =>
updateMany(...args)) as typeof Conversation.updateMany)
.mockImplementationOnce(() => {
throw new Error('later batch update failed');
});
try {
await expect(archiveAllConvos('user123')).rejects.toThrow('later batch update failed');
} finally {
updateManySpy.mockRestore();
}
const archivedCount = await Conversation.countDocuments({
user: 'user123',
isArchived: true,
});
expect(archivedCount).toBe(500);
const remaining = await Conversation.findOne({
user: 'user123',
isArchived: { $ne: true },
}).lean<IConversation>();
const refreshed = await ChatProject.findById(project._id).lean<IChatProject>();
expect(refreshed?.conversationCount).toBe(1);
expect(refreshed?.lastConversationId).toBe(remaining?.conversationId);
});
it('reconciles projects when an archive write commits but its result is lost', async () => {
const destinationProject = await ChatProject.create({ user: 'user123', name: 'Destination' });
const conversationId = uuidv4();
await Conversation.create({
conversationId,
user: 'user123',
endpoint: EModelEndpoint.openAI,
});
/** The chat carries no project when the sweep captures it and the write never
* reports a modified count, so the marker is the only thing left that knows the
* archive happened at all. */
const updateMany = Conversation.updateMany.bind(Conversation);
const updateManySpy = jest.spyOn(Conversation, 'updateMany').mockImplementationOnce((async (
filter,
update,
options,
/** Annotated because a mock that only ever throws infers `Promise<never>`, which
* the cast back to the real signature rejects. */
): Promise<UpdateResult> => {
await updateMany(filter, update, options);
await Conversation.updateOne(
{ conversationId },
{ $set: { chatProjectId: destinationProject._id!.toString() } },
);
await ChatProject.findByIdAndUpdate(destinationProject._id, {
conversationCount: 1,
lastConversationId: conversationId,
});
throw new Error('connection reset before the write acknowledged');
}) as typeof Conversation.updateMany);
try {
await expect(archiveAllConvos('user123')).rejects.toThrow(
'connection reset before the write acknowledged',
);
} finally {
updateManySpy.mockRestore();
}
const archived = await Conversation.findOne({ conversationId }).lean<IConversation>();
expect(archived?.isArchived).toBe(true);
const refreshed = await ChatProject.findById(destinationProject._id).lean<IChatProject>();
expect(refreshed?.conversationCount).toBe(0);
expect(refreshed?.lastConversationId).toBeNull();
});
it('does not let a save in flight restore the project pointer it just cleared', async () => {
const project = await ChatProject.create({ user: 'user123', name: 'Project' });
const projectId = project._id!.toString();
const conversationId = uuidv4();
const anchor = new Date('2026-01-01T00:00:00.000Z');
await Conversation.collection.insertOne({
conversationId,
user: 'user123',
title: 'legacy chat with no isTemporary',
endpoint: EModelEndpoint.openAI,
chatProjectId: projectId,
expiredAt: null,
isArchived: false,
createdAt: anchor,
updatedAt: anchor,
});
await ChatProject.findByIdAndUpdate(project._id, {
conversationCount: 1,
lastConversationAt: anchor,
lastConversationId: conversationId,
});
/** The sweep lands after the save's own write has returned, so the save carries a
* document that still says the chat is visible. Re-sending the unchanged
* `chatProjectId`, as a client saving into an open project chat does, is what puts
* the tail on the pointer branch instead of the recompute one. */
const findOneAndUpdate = Conversation.findOneAndUpdate.bind(Conversation);
const findOneAndUpdateSpy = jest
.spyOn(Conversation, 'findOneAndUpdate')
.mockImplementationOnce((async (filter, update, options) => {
const result = await findOneAndUpdate(filter, update, options);
await archiveAllConvos('user123');
return result;
}) as typeof Conversation.findOneAndUpdate);
try {
await saveConvo(
{ userId: 'user123' },
{ conversationId, chatProjectId: projectId, title: 'Renamed while the sweep ran' },
{ noUpsert: true },
);
} finally {
findOneAndUpdateSpy.mockRestore();
}
const archived = await Conversation.findOne({ conversationId }).lean<IConversation>();
expect(archived?.isArchived).toBe(true);
const refreshed = await ChatProject.findById(project._id).lean<IChatProject>();
expect(refreshed?.lastConversationId).toBeNull();
expect(refreshed?.lastConversationAt).toBeNull();
expect(refreshed?.conversationCount).toBe(0);
});
it('repairs the project when the sweep lands while the pointer write is in flight', async () => {
const project = await ChatProject.create({ user: 'user123', name: 'Project' });
const projectId = project._id!.toString();
const conversationId = uuidv4();
const anchor = new Date('2026-01-01T00:00:00.000Z');
await Conversation.collection.insertOne({
conversationId,
user: 'user123',
title: 'chat being saved',
endpoint: EModelEndpoint.openAI,
chatProjectId: projectId,
expiredAt: null,
isArchived: false,
createdAt: anchor,
updatedAt: anchor,
});
await ChatProject.findByIdAndUpdate(project._id, {
conversationCount: 1,
lastConversationAt: anchor,
lastConversationId: conversationId,
});
/** The sweep runs after the save has decided the chat is visible and immediately
* before its pointer write commits, which is the one window a check taken before
* that write cannot cover. */
const updateOne = ChatProject.updateOne.bind(ChatProject);
const updateOneSpy = jest.spyOn(ChatProject, 'updateOne').mockImplementationOnce((async (
filter,
update,
options,
) => {
await archiveAllConvos('user123');
return await updateOne(filter, update, options);
}) as typeof ChatProject.updateOne);
try {
await saveConvo(
{ userId: 'user123' },
{ conversationId, chatProjectId: projectId, title: 'Renamed while the sweep ran' },
{ noUpsert: true },
);
} finally {
updateOneSpy.mockRestore();
}
const archived = await Conversation.findOne({ conversationId }).lean<IConversation>();
expect(archived?.isArchived).toBe(true);
const refreshed = await ChatProject.findById(project._id).lean<IChatProject>();
expect(refreshed?.lastConversationId).toBeNull();
expect(refreshed?.lastConversationAt).toBeNull();
expect(refreshed?.conversationCount).toBe(0);
});
it('returns zero when the user has no conversations', async () => {
const result = await archiveAllConvos('user123');
expect(result.archivedCount).toBe(0);
});
});
describe('deleteNullOrEmptyConversations', () => {
it('should delete conversations with null, empty, or missing conversationIds', async () => {
// Since conversationId is required by the schema, we can't create documents with null/missing IDs

View file

@ -1,5 +1,5 @@
import { RetentionMode } from 'librechat-data-provider';
import type { FilterQuery, Model, SortOrder } from 'mongoose';
import type { FilterQuery, Model, SortOrder, Types } from 'mongoose';
import type { DeleteResult } from 'mongoose';
import type { AppConfig, IChatProjectDocument, IConversation, ISharedLink } from '~/types';
import type { MessageMethods } from './message';
@ -31,6 +31,69 @@ type ConversationUpdateResult = {
};
};
const ARCHIVE_CONVERSATION_BATCH_SIZE = 500;
const PROJECT_STATS_REFRESH_CONCURRENCY = 10;
const PROJECT_STATS_REFRESH_MAX_PASSES = 2;
const PROJECT_DISCOVERY_MAX_ATTEMPTS = 3;
async function discoverProjectIds(
Conversation: Model<IConversation>,
filter: FilterQuery<IConversation>,
): Promise<string[]> {
let lastError: unknown;
for (let attempt = 0; attempt < PROJECT_DISCOVERY_MAX_ATTEMPTS; attempt++) {
try {
const currentProjectIds = await Conversation.distinct('chatProjectId', filter);
return currentProjectIds.filter((projectId): projectId is string => Boolean(projectId));
} catch (error) {
lastError = error;
logger.error('[archiveAllConvos] Conversations archived but project discovery failed', error);
}
}
throw lastError;
}
/**
* A project dropped here stays wrong forever: its chats are archived, so no retry of
* archive-all can find them again to recompute against. The likeliest rejection is also
* the most recoverable one, `refreshChatProjectStatsForUser` giving up after the project
* changed under every compare-and-set attempt, so failures are collected and replayed
* once the rest of the run has stopped competing with them.
*/
async function refreshChatProjectStatsInBatches(
mongoose: typeof import('mongoose'),
user: string,
projectIds: Iterable<string>,
): Promise<void> {
let pending = [...projectIds];
for (let pass = 0; pass < PROJECT_STATS_REFRESH_MAX_PASSES && pending.length > 0; pass++) {
const failed: string[] = [];
for (let index = 0; index < pending.length; index += PROJECT_STATS_REFRESH_CONCURRENCY) {
const batch = pending.slice(index, index + PROJECT_STATS_REFRESH_CONCURRENCY);
const results = await Promise.allSettled(
batch.map((projectId) => refreshChatProjectStatsForUser(mongoose, user, projectId)),
);
for (let resultIndex = 0; resultIndex < results.length; resultIndex++) {
const result = results[resultIndex];
if (result.status === 'rejected') {
failed.push(batch[resultIndex]);
logger.error(
`[refreshChatProjectStatsInBatches] Failed to refresh project ${batch[resultIndex]}`,
result.reason,
);
}
}
}
pending = failed;
}
if (pending.length > 0) {
logger.error(
`[refreshChatProjectStatsInBatches] Left ${pending.length} project(s) unreconciled: ${pending.join(', ')}`,
);
}
}
export interface ConversationMethods {
getConvoFiles(conversationId: string): Promise<string[]>;
searchConversation(conversationId: string): Promise<IConversation | null>;
@ -93,6 +156,7 @@ export interface ConversationMethods {
user: string,
filter: FilterQuery<IConversation>,
): Promise<DeleteResult & { messages: DeleteResult; conversationIds: string[] }>;
archiveAllConvos(user: string): Promise<{ archivedCount: number }>;
}
export function createConversationMethods(
@ -491,8 +555,10 @@ export function createConversationMethods(
* refresh: the incremental path only bumps the count for brand-new inserts,
* so a pre-existing chat joining the project would otherwise be uncounted.
*/
const isNewConversation = conversationResult.lastErrorObject?.updatedExisting === false;
const shouldRefreshProjectStats =
projectMembershipChanged ||
isNewConversation ||
typeof update.isArchived === 'boolean' ||
Object.prototype.hasOwnProperty.call(unsetFields, 'isArchived') ||
isRetentionVisibilityUpdate ||
@ -506,7 +572,6 @@ export function createConversationMethods(
userId,
conversation.chatProjectId,
conversation,
conversationResult.lastErrorObject?.updatedExisting === false,
);
}
}
@ -1074,11 +1139,7 @@ export function createConversationMethods(
*/
if (deleted && projectIds.size > 0) {
try {
await Promise.all(
[...projectIds].map((projectId) =>
refreshChatProjectStatsForUser(mongoose, user, projectId),
),
);
await refreshChatProjectStatsInBatches(mongoose, user, projectIds);
} catch (error) {
logger.error('[deleteConvos] Conversations deleted but stats refresh failed', error);
}
@ -1094,6 +1155,133 @@ export function createConversationMethods(
}
}
/**
* Archives every conversation the user can currently see in one pass. Temporary and
* retention-expired conversations are left alone: they are already hidden from the
* chat list, so archiving them would only resurrect them in the archived view.
*/
async function archiveAllConvos(user: string) {
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const projectIds = new Set<string>();
/** One stamp for the whole sweep so the archived view groups the run together
* instead of fanning it out across however long the batching took, and so the
* recovery below can find everything this call committed without holding an id
* per conversation in memory. */
const archivedAt = new Date();
let archivedCount = 0;
/** A write whose result never came back may still have committed, so reconciliation
* keys off the attempt rather than off `archivedCount`: a stepdown between commit and
* acknowledgement would otherwise strand those chats with stale project counts, and no
* retry can find them again because they no longer match the sweep filter. */
let attemptedArchiveWrite = false;
try {
const filter = {
user,
$and: [
{ $or: [{ isArchived: false }, { isArchived: { $exists: false } }] },
getVisibleConversationRetentionFilter(),
],
} as FilterQuery<IConversation>;
const snapshotBoundary = await Conversation.findOne(filter)
.select('_id')
.sort({ _id: -1 })
.lean<Pick<IConversation, '_id'>>();
if (!snapshotBoundary) {
return { archivedCount: 0 };
}
let lastConversationId: Types.ObjectId | null = null;
while (true) {
const idRange = lastConversationId
? { $gt: lastConversationId, $lte: snapshotBoundary._id }
: { $lte: snapshotBoundary._id };
const conversations = await Conversation.find({ ...filter, _id: idRange })
.select('_id chatProjectId')
.sort({ _id: 1 })
.limit(ARCHIVE_CONVERSATION_BATCH_SIZE)
.lean<Array<Pick<IConversation, '_id' | 'chatProjectId'>>>();
if (conversations.length === 0) {
break;
}
const conversationIds: Types.ObjectId[] = [];
for (const conversation of conversations) {
conversationIds.push(conversation._id);
if (conversation.chatProjectId) {
projectIds.add(conversation.chatProjectId);
}
}
lastConversationId = conversationIds[conversationIds.length - 1];
/**
* `timestamps: false` keeps each conversation's own `updatedAt`, so the archived
* view stays sorted by real activity instead of collapsing onto the archive time.
* `archivedAt` is still stamped: the filter only matches unarchived chats, so this
* cannot move an existing stamp, and leaving it unset would drop the whole sweep
* into the legacy group the archived table sorts and dates by `createdAt`.
*/
attemptedArchiveWrite = true;
const result = await Conversation.updateMany(
{ ...filter, _id: { $in: conversationIds } },
{ $set: { isArchived: true, archivedAt } },
{ timestamps: false },
);
const batchArchivedCount = result.modifiedCount ?? 0;
archivedCount += batchArchivedCount;
if (batchArchivedCount > 0) {
const currentProjectIds = await discoverProjectIds(Conversation, {
_id: { $in: conversationIds },
user,
});
for (const projectId of currentProjectIds) {
projectIds.add(projectId);
}
}
}
return { archivedCount };
} catch (error) {
logger.error('[archiveAllConvos] Error archiving conversations', error);
throw error;
} finally {
/**
* Best-effort, mirroring `deleteConvos`: committed batches are already archived, so
* a stats failure must not hide that from the caller. Recover destination projects
* first, because in-loop discovery can throw after a move and a retry cannot see
* those already-archived chats. The sweep marker is what identifies them, so this
* costs one indexed query rather than a per-conversation id list: history size
* changes how much this reads, never how much it holds.
*/
if (attemptedArchiveWrite) {
try {
const recoveredProjectIds = await discoverProjectIds(Conversation, {
user,
isArchived: true,
archivedAt,
});
for (const projectId of recoveredProjectIds) {
projectIds.add(projectId);
}
} catch (error) {
logger.error(
'[archiveAllConvos] Conversations archived but project recovery failed',
error,
);
}
}
if (attemptedArchiveWrite && projectIds.size > 0) {
try {
await refreshChatProjectStatsInBatches(mongoose, user, projectIds);
} catch (error) {
logger.error('[archiveAllConvos] Conversations archived but stats refresh failed', error);
}
}
}
}
return {
getConvoFiles,
searchConversation,
@ -1108,5 +1296,6 @@ export function createConversationMethods(
getConvoRetention,
getConvoTitle,
deleteConvos,
archiveAllConvos,
};
}

View file

@ -66,6 +66,7 @@ const convoSchema: Schema<IConversation> = new Schema(
convoSchema.index({ expiredAt: 1 }, { expireAfterSeconds: 0 });
convoSchema.index({ createdAt: 1, updatedAt: 1 });
convoSchema.index({ conversationId: 1, user: 1, tenantId: 1 }, { unique: true });
convoSchema.index({ user: 1, _id: 1 });
convoSchema.index({ user: 1, chatProjectId: 1, updatedAt: -1, _id: -1 });
convoSchema.index({ user: 1, chatProjectId: 1, createdAt: -1, _id: -1 });
/** The archive view pages by `archivedAt`, then `createdAt`, then `_id`; the middle key