🗄️ feat: Record When a Conversation Was Archived (#14863)

* feat: record when a conversation was archived

The archived chats dialog has a "Date Archived" column that was bound to
createdAt, so it showed when the chat was created rather than when it was
filed away. Nothing recorded the latter.

Conversations now carry archivedAt, set on archive and cleared on
unarchive, and the column reads it. Chats archived before the field
existed have no stamp and fall back to createdAt, which is exactly what
that column already showed for them.

The archive view sorts on the new field. archivedAt is absent on every
previously archived chat, so the missing-value group is the common case
here rather than an edge case: the cursor's null handling, written for
titles, now covers both, and an absent stamp survives the cursor as null
instead of collapsing to the epoch and replaying the whole archive.

* fix: address review findings on the archived-at stamp

- Protect `archivedAt` from saveMessageToDatabase's unset sweep. Any
  persisted field missing from endpointOptions is unset, so sending a
  message in an archived chat cleared the stamp while leaving isArchived
  true, silently dropping it into the legacy fallback group.
- Order the legacy group by the createdAt the dialog displays rather than
  by last activity. The cursor's secondary key is now chosen per sort
  field, so the fallback the cell renders and the order the server
  returns cannot disagree.
- Put that secondary key in the archive index too, so paging the legacy
  group does not fall back to a blocking sort.

* fix: keep archivedAt on a redundant archive request

Opening an archived chat and hitting the archive shortcut, or retrying
the POST, sent isArchived: true again and replaced Date Archived with
now. saveConvo now stamps only on the unarchived-to-archived transition
and still clears the field on unarchive.

* fix: make archive timestamp updates atomic

* test: type the archive race spy against the driver signature

* fix: archive without an aggregation-pipeline update

DocumentDB documents no support for pipeline-form updates on any engine version,
and the repository's compatibility assessment records that a prior P0 rewrote the
three that existed. Stamping archivedAt through a $cond pipeline reintroduced one,
which would have sent every archive and unarchive to the route's 500 handler on a
supported 5.0 deployment.

The conditional stamp is now a compare-and-set on isArchived, which keeps the
transition atomic without a pipeline: only the write that finds the chat unarchived
stamps it, so a duplicate or retried archive leaves the original date alone and an
unarchive that lands first is re-stamped. Schema defaults and createdAt-on-insert
go back to mongoose's own setDefaultsOnInsert and $setOnInsert, and tenantId is
once again stripped by the tenant-isolation plugin rather than by hand.

* fix: do not report a racing archive as a missing chat

Both conditional writes of the compare-and-set miss when the archive flag flips
between them: the chat was already archived when the transition write ran and
unarchived again before the already-archived write. saveConvo returned null for
a conversation that plainly exists, so POST /api/convos/archive answered 404.

Confirm the conversation is really gone before accepting that result, and retry
the pair when it is not. An unknown id still costs one existence read and falls
straight through to the 404.

* fix: resolve a fully contended archive to the chat's real state

Alternating archive and unarchive requests can split every attempt of the
compare-and-set: each transition write sees the chat archived and each
already-archived write sees it unarchived. Exhausting the retries therefore
proved nothing about whether the conversation exists, and the no-upsert archive
route turned a lost race back into a 404.

Read the conversation once more when the retries run out and answer with its
actual current state instead.
This commit is contained in:
Marco Beretta 2026-08-16 23:07:44 +02:00 • committed by GitHub
parent 7857a99d63
commit 832bac39ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 757 additions and 77 deletions

View file

@ -542,7 +542,10 @@ describe('Convos Routes', () => {
expect(response.body).toEqual(mockArchivedConvo);
expect(saveConvo).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'test-user-123' }),
{ conversationId: mockConversationId, isArchived: true },
{
conversationId: mockConversationId,
isArchived: true,
},
{
context: `POST /api/convos/archive ${mockConversationId}`,
preserveUpdatedAt: true,
@ -584,6 +587,50 @@ describe('Convos Routes', () => {
);
});
it('leaves archivedAt to saveConvo so a redundant archive cannot restamp it', async () => {
saveConvo.mockResolvedValue({ conversationId: 'conv-789', isArchived: true });
await request(app)
.post('/api/convos/archive')
.send({ arg: { conversationId: 'conv-789', isArchived: true } });
const [, data] = saveConvo.mock.calls[0];
expect(data).not.toHaveProperty('archivedAt');
expect(data.isArchived).toBe(true);
});
/** `updatedAt` stays the chat's own activity so unarchiving restores its real place
* in the date groups; when it was filed away is recorded on `archivedAt` instead. */
it('does not let archiving count as activity in the sidebar ordering', async () => {
saveConvo.mockResolvedValue({ conversationId: 'conv-789', isArchived: true });
await request(app)
.post('/api/convos/archive')
.send({ arg: { conversationId: 'conv-789', isArchived: true } });
expect(saveConvo).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ preserveUpdatedAt: true }),
);
});
it('should return 404 when the conversation does not exist', async () => {
saveConvo.mockResolvedValue(null);
const response = await request(app)
.post('/api/convos/archive')
.send({ arg: { conversationId: 'missing-convo', isArchived: true } });
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'Conversation not found' });
expect(saveConvo).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ noUpsert: true }),
);
});
it('should return 400 when conversationId is missing', async () => {
const response = await request(app)
.post('/api/convos/archive')
@ -672,6 +719,17 @@ describe('Convos Routes', () => {
expect(setConvoPinned).toHaveBeenCalledWith('test-user-123', mockConversationId, true);
});
it('should unpin a conversation', async () => {
const mockUnpinnedConvo = { conversationId: mockConversationId, pinned: false };
setConvoPinned.mockResolvedValue(mockUnpinnedConvo);
const response = await request(app).post('/api/convos/pin').send({ arg: mockUnpinnedConvo });
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUnpinnedConvo);
expect(setConvoPinned).toHaveBeenCalledWith('test-user-123', mockConversationId, false);
});
/** A pin is one boolean: it must not drag in `saveConvo`'s message-id refresh
* and project-stats recompute, which cost an extra read and a large write. */
it('does not route a pin through the full conversation save', async () => {
@ -685,17 +743,6 @@ describe('Convos Routes', () => {
expect(saveConvo).not.toHaveBeenCalled();
});
it('should unpin a conversation', async () => {
const mockUnpinnedConvo = { conversationId: mockConversationId, pinned: false };
setConvoPinned.mockResolvedValue(mockUnpinnedConvo);
const response = await request(app).post('/api/convos/pin').send({ arg: mockUnpinnedConvo });
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUnpinnedConvo);
expect(setConvoPinned).toHaveBeenCalledWith('test-user-123', mockConversationId, false);
});
it('should return 404 when the conversation does not exist', async () => {
setConvoPinned.mockResolvedValue(null);

View file

@ -211,7 +211,8 @@ router.post('/archive', validateConvoAccess, async (req, res) => {
{
context: `POST /api/convos/archive ${conversationId}`,
/** Filing a chat away is not activity: `updatedAt` stays the chat's own last
* activity so unarchiving restores it to its real place in the date groups. */
* activity so unarchiving restores it to its real place in the date groups.
* When it was archived is recorded separately, on `archivedAt`. */
preserveUpdatedAt: true,
/** Without timestamps, an upsert would insert a conversation that has none. */
noUpsert: true,

View file

@ -31,11 +31,20 @@ import store from '~/store';
const DEFAULT_PARAMS: ConversationListParams = {
isArchived: true,
sortBy: 'createdAt',
sortBy: 'archivedAt',
sortDirection: 'desc',
search: '',
};
const SORTABLE_COLUMNS = new Set<ConversationListParams['sortBy']>(['title', 'archivedAt']);
/**
* Chats archived before `archivedAt` was recorded have none, so they keep showing the
* date this column has always shown for them rather than going blank.
*/
const getArchivedDate = (conversation: TConversation): string =>
conversation.archivedAt?.toString() ?? conversation.createdAt?.toString() ?? '';
type ArchivedConversationRow = TConversation & Record<string, unknown>;
export default function ArchivedChatsTable() {
@ -79,7 +88,7 @@ export default function ArchivedChatsTable() {
const sorting = useMemo<SortingState>(
() => [
{
id: queryParams.sortBy ?? 'createdAt',
id: queryParams.sortBy ?? 'archivedAt',
desc: queryParams.sortDirection === 'desc',
},
],
@ -89,22 +98,23 @@ export default function ArchivedChatsTable() {
const handleSortingChange = useCallback((updater: Updater<SortingState>) => {
setQueryParams((prev) => {
const currentSorting: SortingState = [
{ id: prev.sortBy ?? 'createdAt', desc: prev.sortDirection === 'desc' },
{ id: prev.sortBy ?? 'archivedAt', desc: prev.sortDirection === 'desc' },
];
const nextSorting = typeof updater === 'function' ? updater(currentSorting) : updater;
const nextSort = nextSorting[0];
const nextSortBy = nextSort?.id as ConversationListParams['sortBy'];
if (nextSort?.id !== 'title' && nextSort?.id !== 'createdAt') {
if (!SORTABLE_COLUMNS.has(nextSortBy)) {
return {
...prev,
sortBy: 'createdAt',
sortBy: 'archivedAt',
sortDirection: 'desc',
};
}
return {
...prev,
sortBy: nextSort.id,
sortBy: nextSortBy,
sortDirection: nextSort.desc ? 'desc' : 'asc',
};
});
@ -191,9 +201,9 @@ export default function ArchivedChatsTable() {
},
},
{
accessorKey: 'createdAt',
accessorKey: 'archivedAt',
header: localize('com_nav_archive_created_at'),
cell: ({ row }) => formatDate(row.original.createdAt?.toString() ?? '', isSmallScreen),
cell: ({ row }) => formatDate(getArchivedDate(row.original), isSmallScreen),
meta: {
width: 25,
desktopOnly: true,

View file

@ -65,6 +65,7 @@ export const excludedKeys = new Set([
'messages',
'isArchived',
'pinned',
'archivedAt',
'tags',
'user',
'__v',

View file

@ -946,6 +946,8 @@ export const tConversationSchema = z.object({
endpoint: eModelEndpointSchema.nullable(),
endpointType: eModelEndpointSchema.nullable().optional(),
isArchived: z.boolean().optional(),
/** When the chat was archived; absent on chats archived before this was recorded. */
archivedAt: z.string().nullable().optional(),
pinned: z.boolean().optional(),
/** Server-derived: an active shared link exists for this conversation. Not persisted. */
isShared: z.boolean().optional(),

View file

@ -17,7 +17,7 @@ export type ConversationListParams = {
limit?: number;
isArchived?: boolean;
pinned?: boolean;
sortBy?: 'title' | 'createdAt' | 'updatedAt';
sortBy?: 'title' | 'createdAt' | 'updatedAt' | 'archivedAt';
sortDirection?: 'asc' | 'desc';
tags?: string[];
search?: string;
@ -31,6 +31,7 @@ export type MinimalConversation = Pick<
| 'title'
| 'createdAt'
| 'updatedAt'
| 'archivedAt'
| 'user'
| 'chatProjectId'
| 'pinned'

View file

@ -2,6 +2,7 @@ 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 { IChatProject, IConversation } from '../types';
import { ConversationMethods, createConversationMethods } from './conversation';
import { tenantStorage, runAsSystem } from '~/config/tenantContext';
@ -392,6 +393,31 @@ describe('Conversation Operations', () => {
expect(secondSave?.title).toBe('Updated title');
});
it('preserves insert defaults and suppressed timestamps for an archive pipeline upsert', async () => {
const conversationId = uuidv4();
const firstAnchor = new Date('2024-02-03T04:05:06.000Z');
const secondAnchor = new Date('2025-02-03T04:05:06.000Z');
const firstSave = await saveConvo(
{ userId: 'user123' },
{ conversationId, isArchived: true },
{ createdAtOnInsert: firstAnchor, preserveUpdatedAt: true },
);
const secondSave = await saveConvo(
{ userId: 'user123' },
{ conversationId, isArchived: true },
{ createdAtOnInsert: secondAnchor, preserveUpdatedAt: true },
);
expect(firstSave?.title).toBe('New Chat');
expect(firstSave?.isTemporary).toBe(false);
expect(firstSave?.updatedAt ?? null).toBeNull();
expect(new Date(firstSave?.createdAt ?? 0).toISOString()).toBe(firstAnchor.toISOString());
expect(secondSave?.updatedAt ?? null).toBeNull();
expect(new Date(secondSave?.createdAt ?? 0).toISOString()).toBe(firstAnchor.toISOString());
expect(secondSave?.archivedAt).toEqual(firstSave?.archivedAt);
});
describe('preserveUpdatedAt', () => {
const anchor = new Date('2026-02-11T15:28:18.561Z');
@ -432,6 +458,315 @@ describe('Conversation Operations', () => {
expect(new Date(unarchived?.updatedAt ?? 0).toISOString()).toBe(anchor.toISOString());
});
/** Opening an archived chat and hitting the archive shortcut (or a retried
* POST) sends isArchived: true again. That must not move Date Archived. */
it('keeps the original archivedAt when the chat is already archived', async () => {
const conversationId = uuidv4();
const original = new Date('2026-03-01T12:00:00.000Z');
await Conversation.collection.insertOne({
conversationId,
user: 'user123',
title: 'Already filed away',
endpoint: EModelEndpoint.openAI,
expiredAt: null,
isArchived: true,
archivedAt: original,
createdAt: original,
updatedAt: original,
});
const again = await saveConvo(
{ userId: 'user123' },
{
conversationId,
isArchived: true,
archivedAt: new Date('2026-08-15T21:00:00.000Z'),
},
{ preserveUpdatedAt: true, noUpsert: true },
);
expect(new Date(again?.archivedAt ?? 0).toISOString()).toBe(original.toISOString());
});
/** DocumentDB documents no support for pipeline-form updates on any engine
* version (`misc/documentdb/documentdb-compat.md`), so both archive transitions
* have to stay on plain update operators. */
it('archives with plain update operators rather than an aggregation pipeline', async () => {
const conversationId = uuidv4();
const original = new Date('2026-03-01T12:00:00.000Z');
await Conversation.collection.insertOne({
conversationId,
user: 'user123',
title: 'Plain operators only',
endpoint: EModelEndpoint.openAI,
expiredAt: null,
isArchived: false,
createdAt: original,
updatedAt: original,
});
const findOneAndUpdate = Conversation.collection.findOneAndUpdate.bind(
Conversation.collection,
);
const updates: Array<UpdateFilter<Document> | Document[]> = [];
const writeSpy = jest
.spyOn(Conversation.collection, 'findOneAndUpdate')
.mockImplementation(
async (
filter: Filter<Document>,
update: UpdateFilter<Document> | Document[],
options?: FindOneAndUpdateOptions,
) => {
updates.push(update);
return findOneAndUpdate(filter, update, options ?? {});
},
);
try {
const archived = await saveConvo(
{ userId: 'user123' },
{ conversationId, isArchived: true },
{ preserveUpdatedAt: true, noUpsert: true },
);
const unarchived = await saveConvo(
{ userId: 'user123' },
{ conversationId, isArchived: false },
{ preserveUpdatedAt: true, noUpsert: true },
);
expect(archived?.archivedAt).toBeInstanceOf(Date);
expect(unarchived?.archivedAt ?? null).toBeNull();
expect(updates.length).toBeGreaterThan(0);
for (const update of updates) {
expect(Array.isArray(update)).toBe(false);
}
} finally {
writeSpy.mockRestore();
}
});
/** Both conditional writes of the compare-and-set miss when an unarchive lands
* between them. The conversation still exists, so the route must not 404. */
it('does not report a missing chat when an unarchive splits the archive writes', async () => {
const conversationId = uuidv4();
const original = new Date('2026-03-01T12:00:00.000Z');
await Conversation.collection.insertOne({
conversationId,
user: 'user123',
title: 'Split archive',
endpoint: EModelEndpoint.openAI,
expiredAt: null,
isArchived: true,
archivedAt: original,
createdAt: original,
updatedAt: original,
});
const findOneAndUpdate = Conversation.collection.findOneAndUpdate.bind(
Conversation.collection,
);
let writeCount = 0;
const writeSpy = jest
.spyOn(Conversation.collection, 'findOneAndUpdate')
.mockImplementation(
async (
filter: Filter<Document>,
update: UpdateFilter<Document> | Document[],
options?: FindOneAndUpdateOptions,
) => {
writeCount += 1;
const result = await findOneAndUpdate(filter, update, options ?? {});
/** The transition write has just missed on an already archived chat.
* Unarchive it now so the already-archived write misses too. */
if (writeCount === 1) {
await Conversation.collection.updateOne(
{ conversationId },
{ $set: { isArchived: false, archivedAt: null } },
);
}
return result;
},
);
try {
const archived = await saveConvo(
{ userId: 'user123' },
{ conversationId, isArchived: true },
{ preserveUpdatedAt: true, noUpsert: true },
);
expect(archived).not.toBeNull();
expect(archived?.isArchived).toBe(true);
expect(archived?.archivedAt).toBeInstanceOf(Date);
} finally {
writeSpy.mockRestore();
}
});
/** Alternating requests can split every retry, and exhausting them still proves
* nothing about whether the chat exists, so it must not become a 404 either. */
it('does not report a missing chat when every archive retry is split', async () => {
const conversationId = uuidv4();
const original = new Date('2026-03-01T12:00:00.000Z');
await Conversation.collection.insertOne({
conversationId,
user: 'user123',
title: 'Perpetually split',
endpoint: EModelEndpoint.openAI,
expiredAt: null,
isArchived: true,
archivedAt: original,
createdAt: original,
updatedAt: original,
});
const findOneAndUpdate = Conversation.collection.findOneAndUpdate.bind(
Conversation.collection,
);
const writeSpy = jest
.spyOn(Conversation.collection, 'findOneAndUpdate')
.mockImplementation(
async (
filter: Filter<Document>,
update: UpdateFilter<Document> | Document[],
options?: FindOneAndUpdateOptions,
) => {
const result = await findOneAndUpdate(filter, update, options ?? {});
/** Flip the flag after every write so no transition write and no
* already-archived write can ever match. */
const archived = await Conversation.collection.findOne({ conversationId });
await Conversation.collection.updateOne(
{ conversationId },
{ $set: { isArchived: archived?.isArchived !== true } },
);
return result;
},
);
try {
const archived = await saveConvo(
{ userId: 'user123' },
{ conversationId, isArchived: true },
{ preserveUpdatedAt: true, noUpsert: true },
);
expect(archived).not.toBeNull();
expect(archived?.conversationId).toBe(conversationId);
} finally {
writeSpy.mockRestore();
}
});
it('stamps again when unarchive lands before a pending repeated archive write', async () => {
const conversationId = uuidv4();
const original = new Date('2026-03-01T12:00:00.000Z');
await Conversation.collection.insertOne({
conversationId,
user: 'user123',
title: 'Archive race',
endpoint: EModelEndpoint.openAI,
expiredAt: null,
isArchived: true,
archivedAt: original,
createdAt: original,
updatedAt: original,
});
let markArchiveWriteReached = () => {};
const archiveWriteReached = new Promise<void>((resolve) => {
markArchiveWriteReached = resolve;
});
let releaseArchiveWrite = () => {};
const archiveWriteBlocked = new Promise<void>((resolve) => {
releaseArchiveWrite = resolve;
});
const findOneAndUpdate = Conversation.collection.findOneAndUpdate.bind(
Conversation.collection,
);
let writeCount = 0;
const writeSpy = jest
.spyOn(Conversation.collection, 'findOneAndUpdate')
.mockImplementation(
async (
filter: Filter<Document>,
update: UpdateFilter<Document> | Document[],
options?: FindOneAndUpdateOptions,
) => {
writeCount += 1;
if (writeCount === 1) {
markArchiveWriteReached();
await archiveWriteBlocked;
}
return findOneAndUpdate(filter, update, options ?? {});
},
);
try {
const archive = saveConvo(
{ userId: 'user123' },
{ conversationId, isArchived: true },
{ preserveUpdatedAt: true, noUpsert: true },
);
await archiveWriteReached;
const unarchived = await saveConvo(
{ userId: 'user123' },
{ conversationId, isArchived: false },
{ preserveUpdatedAt: true, noUpsert: true },
);
releaseArchiveWrite();
const archived = await archive;
expect(unarchived?.isArchived).toBe(false);
expect(archived?.isArchived).toBe(true);
expect(archived?.archivedAt).toBeInstanceOf(Date);
expect(new Date(archived?.archivedAt ?? 0).toISOString()).not.toBe(
original.toISOString(),
);
} finally {
releaseArchiveWrite();
writeSpy.mockRestore();
}
});
it('stamps archivedAt on the first archive when the caller omits it', async () => {
const conversationId = await seedAgedConvo();
const before = Date.now();
const archived = await saveConvo(
{ userId: 'user123' },
{ conversationId, isArchived: true },
{ preserveUpdatedAt: true, noUpsert: true },
);
expect(archived?.archivedAt).toBeInstanceOf(Date);
expect(new Date(archived?.archivedAt ?? 0).getTime()).toBeGreaterThanOrEqual(before);
});
it('clears archivedAt on unarchive when the caller omits it', async () => {
const conversationId = uuidv4();
const original = new Date('2026-03-01T12:00:00.000Z');
await Conversation.collection.insertOne({
conversationId,
user: 'user123',
title: 'To restore',
endpoint: EModelEndpoint.openAI,
expiredAt: null,
isArchived: true,
archivedAt: original,
createdAt: original,
updatedAt: original,
});
const unarchived = await saveConvo(
{ userId: 'user123' },
{ conversationId, isArchived: false },
{ preserveUpdatedAt: true, noUpsert: true },
);
expect(unarchived?.archivedAt ?? null).toBeNull();
});
/** A pin carries a preserved older timestamp, so the project it belongs to must
* not be dragged back to it while the project holds newer conversations. */
it('does not drag a project pointer back when only metadata changes', async () => {
@ -1809,6 +2144,174 @@ describe('Conversation Operations', () => {
});
});
describe('getConvosByCursor archivedAt sorting', () => {
const insertArchived = async (title: string, archivedAt: Date | null, updatedAt: Date) => {
const conversationId = uuidv4();
await Conversation.collection.insertOne({
conversationId,
user: 'user123',
title,
endpoint: EModelEndpoint.openAI,
expiredAt: null,
isArchived: true,
createdAt: updatedAt,
updatedAt,
...(archivedAt === null ? {} : { archivedAt }),
});
return conversationId;
};
it('accepts archivedAt as a sort field', async () => {
const base = new Date('2026-06-01T00:00:00.000Z');
const older = await insertArchived('older', new Date(base.getTime() - 60000), base);
const newer = await insertArchived('newer', new Date(base.getTime() + 60000), base);
const result = await getConvosByCursor('user123', {
isArchived: true,
sortBy: 'archivedAt',
sortDirection: 'desc',
});
expect(result.conversations.map((convo) => convo.conversationId)).toEqual([newer, older]);
});
/* Everything archived before the field existed has no stamp, so the missing-value
group is the common case here, not an edge case. A cursor that collapsed a null
stamp to the epoch would restart paging from 1970 and replay the whole archive. */
it('pages across stamped and unstamped chats without repeating or dropping any', async () => {
const base = new Date('2026-06-01T00:00:00.000Z');
const expected = new Set<string>();
for (let index = 0; index < 4; index++) {
expected.add(
await insertArchived(`stamped ${index}`, new Date(base.getTime() + index * 60000), base),
);
}
for (let index = 0; index < 4; index++) {
expected.add(
await insertArchived(`legacy ${index}`, null, new Date(base.getTime() + index * 60000)),
);
}
const seen: string[] = [];
let cursor: string | null = null;
for (let page = 0; page < 10; page++) {
const result = await getConvosByCursor('user123', {
isArchived: true,
sortBy: 'archivedAt',
sortDirection: 'desc',
limit: 3,
cursor,
});
seen.push(...result.conversations.map((convo) => convo.conversationId));
cursor = result.nextCursor;
if (!cursor) {
break;
}
}
expect(new Set(seen)).toEqual(expected);
expect(seen.length).toBe(expected.size);
});
it('orders unstamped chats last when sorting newest first', async () => {
const base = new Date('2026-06-01T00:00:00.000Z');
const stamped = await insertArchived('stamped', base, base);
const legacy = await insertArchived('legacy', null, base);
const result = await getConvosByCursor('user123', {
isArchived: true,
sortBy: 'archivedAt',
sortDirection: 'desc',
});
expect(result.conversations.map((convo) => convo.conversationId)).toEqual([stamped, legacy]);
});
/** Ascending takes the other null branch: the unstamped group comes first and the
* page that leaves it has to pick up every stamped chat behind it. */
it('pages across stamped and unstamped chats when sorting oldest first', async () => {
const base = new Date('2026-06-01T00:00:00.000Z');
const expected = new Set<string>();
for (let index = 0; index < 3; index++) {
expected.add(
await insertArchived(`stamped ${index}`, new Date(base.getTime() + index * 60000), base),
);
}
for (let index = 0; index < 3; index++) {
expected.add(
await insertArchived(`legacy ${index}`, null, new Date(base.getTime() + index * 60000)),
);
}
const seen: string[] = [];
let cursor: string | null = null;
for (let page = 0; page < 10; page++) {
const result = await getConvosByCursor('user123', {
isArchived: true,
sortBy: 'archivedAt',
sortDirection: 'asc',
limit: 2,
cursor,
});
seen.push(...result.conversations.map((convo) => convo.conversationId));
cursor = result.nextCursor;
if (!cursor) {
break;
}
}
expect(new Set(seen)).toEqual(expected);
expect(seen.length).toBe(expected.size);
});
/** The cell renders `archivedAt ?? createdAt`, so the legacy group has to come back
* in that same createdAt order; ordering it by last activity read out of order. */
it('orders the legacy group by the createdAt it displays, not last activity', async () => {
const base = new Date('2026-06-01T00:00:00.000Z');
const olderCreated = uuidv4();
const newerCreated = uuidv4();
/* createdAt and updatedAt deliberately disagree: ordering by activity would
invert these two relative to the dates the dialog shows. */
await Conversation.collection.insertOne({
conversationId: olderCreated,
user: 'user123',
title: 'created first, touched last',
endpoint: EModelEndpoint.openAI,
expiredAt: null,
isArchived: true,
createdAt: new Date(base.getTime() - 60000),
updatedAt: new Date(base.getTime() + 60000),
});
await Conversation.collection.insertOne({
conversationId: newerCreated,
user: 'user123',
title: 'created last, touched first',
endpoint: EModelEndpoint.openAI,
expiredAt: null,
isArchived: true,
createdAt: new Date(base.getTime() + 60000),
updatedAt: new Date(base.getTime() - 60000),
});
const result = await getConvosByCursor('user123', {
isArchived: true,
sortBy: 'archivedAt',
sortDirection: 'desc',
});
expect(result.conversations.map((convo) => convo.conversationId)).toEqual([
newerCreated,
olderCreated,
]);
});
it('still rejects a sort field that is not allowed', async () => {
await expect(getConvosByCursor('user123', { sortBy: 'pinned' })).rejects.toThrow(
/Invalid sortBy field/,
);
});
});
describe('getConvosByCursor pinned filter', () => {
const insertConvo = async ({
user = 'user123',

View file

@ -18,6 +18,19 @@ import { isValidObjectIdString } from '~/utils/objectId';
import { decrementTagCounts } from './conversationTag';
import logger from '~/config/winston';
type ConversationUpdateResult = {
value:
| (IConversation & {
_id: unknown;
$isDefault: (path: string) => boolean;
toObject: () => IConversation;
})
| null;
lastErrorObject?: {
updatedExisting?: boolean;
};
};
export interface ConversationMethods {
getConvoFiles(conversationId: string): Promise<string[]>;
searchConversation(conversationId: string): Promise<IConversation | null>;
@ -295,44 +308,110 @@ export function createConversationMethods(
? metadata.createdAtOnInsert
: undefined;
/**
* Metadata-only edits (pinning, archiving) must not read as activity: the
* sidebar orders chats by `updatedAt`, so bumping it would hoist an untouched
* chat to Today and misplace it again the moment the flag is cleared.
* Metadata-only edits (pinning) must not read as activity: the sidebar orders
* chats by `updatedAt`, so bumping it would hoist an untouched chat to Today.
*/
const preserveUpdatedAt = metadata?.preserveUpdatedAt === true;
if (createdAtOnInsert && !preserveUpdatedAt) {
const updatesArchiveState = typeof update.isArchived === 'boolean';
if (!preserveUpdatedAt && (createdAtOnInsert || updatesArchiveState)) {
update.updatedAt = new Date();
}
const updateOperation: Record<string, unknown> = { $set: update };
if (Object.keys(unsetFields).length > 0) {
updateOperation.$unset = unsetFields;
}
if (createdAtOnInsert) {
updateOperation.$setOnInsert = { createdAt: createdAtOnInsert };
const timestampOptions: { timestamps?: false } = {};
if (updatesArchiveState || createdAtOnInsert || preserveUpdatedAt) {
timestampOptions.timestamps = false;
}
const conversationResult = (await Conversation.findOneAndUpdate(
{ conversationId, user: userId },
updateOperation,
{
new: true,
upsert: metadata?.noUpsert !== true,
includeResultMetadata: true,
...(createdAtOnInsert || preserveUpdatedAt ? { timestamps: false } : {}),
},
)) as unknown as {
value:
| (IConversation & {
_id: unknown;
$isDefault: (path: string) => boolean;
toObject: () => IConversation;
})
| null;
lastErrorObject?: {
updatedExisting?: boolean;
};
const buildOperation = (setFields: Record<string, unknown>) => {
const operation: Record<string, unknown> = { $set: setFields };
if (Object.keys(unsetFields).length > 0) {
operation.$unset = unsetFields;
}
const createdAtForInsert = updatesArchiveState
? (createdAtOnInsert ??
(!preserveUpdatedAt && update.updatedAt instanceof Date ? update.updatedAt : undefined))
: createdAtOnInsert;
if (createdAtForInsert) {
operation.$setOnInsert = { createdAt: createdAtForInsert };
}
return operation;
};
const baseFilter = { conversationId, user: userId };
const canUpsert = metadata?.noUpsert !== true;
const runUpdate = (
filter: Record<string, unknown>,
operation: Record<string, unknown>,
upsert: boolean,
) =>
Conversation.findOneAndUpdate(filter, operation, {
new: true,
upsert,
includeResultMetadata: true,
...timestampOptions,
}) as unknown as Promise<ConversationUpdateResult>;
let conversationResult: ConversationUpdateResult;
if (update.isArchived === true) {
/** DocumentDB documents no support for pipeline-form updates on any engine
* version (`misc/documentdb/documentdb-compat.md`), so the stamp is applied
* by compare-and-set instead of `$cond`. Matching on the flag is what keeps
* it atomic: only the write that finds the chat unarchived stamps it, so a
* duplicate or retried archive cannot move `archivedAt`, and an unarchive
* that lands first is re-stamped rather than left bare. */
const withoutStamp = { ...update };
delete withoutStamp.archivedAt;
const stamped = { ...withoutStamp, archivedAt: update.archivedAt ?? new Date() };
const runArchiveWrites = async () => {
const transitioned = await runUpdate(
{ ...baseFilter, isArchived: { $ne: true } },
buildOperation(stamped),
false,
);
if (transitioned.value) {
return transitioned;
}
return runUpdate(
{ ...baseFilter, isArchived: true },
buildOperation(withoutStamp),
false,
);
};
conversationResult = await runArchiveWrites();
/** Both conditional writes miss when the flag flips between them: the chat was
* already archived when the first ran and unarchived again before the second.
* That is a live conversation, so confirm it is really gone before letting the
* route answer 404, and retry the pair when it is not. */
for (let attempt = 0; !conversationResult.value && attempt < 2; attempt++) {
if (!(await Conversation.exists(baseFilter))) {
break;
}
conversationResult = await runArchiveWrites();
}
if (!conversationResult.value && canUpsert) {
conversationResult = await runUpdate(baseFilter, buildOperation(stamped), true);
}
if (!conversationResult.value) {
/** Alternating archive and unarchive requests can split every attempt, so
* exhausting the retries still proves nothing about whether the chat exists.
* Answer with its actual current state rather than reporting it missing. */
const current = await Conversation.findOne(baseFilter);
if (current) {
conversationResult = {
value: current as unknown as ConversationUpdateResult['value'],
lastErrorObject: { updatedExisting: true },
};
}
}
} else {
conversationResult = await runUpdate(
baseFilter,
buildOperation(updatesArchiveState ? { ...update, archivedAt: null } : update),
canUpsert,
);
}
const conversation = conversationResult.value;
if (!conversation) {
@ -682,7 +761,7 @@ export function createConversationMethods(
}
}
const validSortFields = ['title', 'createdAt', 'updatedAt'];
const validSortFields = ['title', 'createdAt', 'updatedAt', 'archivedAt'];
if (!validSortFields.includes(sortBy)) {
throw new Error(
`Invalid sortBy field: ${sortBy}. Must be one of ${validSortFields.join(', ')}`,
@ -690,6 +769,14 @@ export function createConversationMethods(
}
const finalSortBy = sortBy;
const finalSortDirection = sortDirection === 'asc' ? 'asc' : 'desc';
/* `title` and `archivedAt` are both absent on plenty of rows, and BSON orders a
missing field before every string and every date alike. The paging clauses below
therefore treat them the same way; `createdAt`/`updatedAt` are always present. */
const sortFieldIsNullable = finalSortBy === 'title' || finalSortBy === 'archivedAt';
/* The archive view renders `archivedAt ?? createdAt`, so the legacy group, which
shares a missing `archivedAt`, has to be ordered by the same `createdAt` the cell
shows rather than by last activity, or its dates read out of order. */
const secondaryField = finalSortBy === 'archivedAt' ? 'createdAt' : 'updatedAt';
let cursorFilter: FilterQuery<IConversation> | null = null;
if (cursor) {
@ -699,7 +786,7 @@ export function createConversationMethods(
const secondaryValue = new Date(secondary);
const descending = finalSortDirection !== 'asc';
const op = descending ? '$lt' : '$gt';
const sortsByUpdatedAt = finalSortBy === 'updatedAt';
const sortsBySecondary = finalSortBy === secondaryField;
const boundaryId =
typeof id === 'string' && isValidObjectIdString(id)
? { [op]: new mongoose.Types.ObjectId(id) }
@ -711,15 +798,19 @@ export function createConversationMethods(
(sort field, updatedAt) pair is skipped instead of returned. */
const clauses: FilterQuery<IConversation>[] = [];
/* A title can be absent, and BSON orders a missing field before every
string while `$lt`/`$gt` never cross that type boundary. Titleless rows
therefore need clauses of their own: their own tail when the boundary is
one of them, and the whole group when a descending page runs past the
last title. */
/* A title or an archive stamp can be absent, and BSON orders a missing field
before every string and date while `$lt`/`$gt` never cross that type
boundary. Those rows therefore need clauses of their own: their own tail
when the boundary is one of them, and the whole group when a descending
page runs past the last non-null value. */
if (primary == null) {
clauses.push({ [finalSortBy]: null, updatedAt: { [op]: secondaryValue } });
clauses.push({ [finalSortBy]: null, [secondaryField]: { [op]: secondaryValue } });
if (boundaryId) {
clauses.push({ [finalSortBy]: null, updatedAt: secondaryValue, _id: boundaryId });
clauses.push({
[finalSortBy]: null,
[secondaryField]: secondaryValue,
_id: boundaryId,
});
}
if (!descending) {
clauses.push({ [finalSortBy]: { $ne: null } });
@ -727,17 +818,20 @@ export function createConversationMethods(
} else {
const primaryValue = finalSortBy === 'title' ? primary : new Date(primary);
clauses.push({ [finalSortBy]: { [op]: primaryValue } });
if (!sortsByUpdatedAt) {
clauses.push({ [finalSortBy]: primaryValue, updatedAt: { [op]: secondaryValue } });
if (!sortsBySecondary) {
clauses.push({
[finalSortBy]: primaryValue,
[secondaryField]: { [op]: secondaryValue },
});
}
if (boundaryId) {
clauses.push({
[finalSortBy]: primaryValue,
...(sortsByUpdatedAt ? {} : { updatedAt: secondaryValue }),
...(sortsBySecondary ? {} : { [secondaryField]: secondaryValue }),
_id: boundaryId,
});
}
if (descending && finalSortBy === 'title') {
if (descending && sortFieldIsNullable) {
clauses.push({ [finalSortBy]: null });
}
}
@ -758,14 +852,14 @@ export function createConversationMethods(
const sortOrder: SortOrder = finalSortDirection === 'asc' ? 1 : -1;
const sortObj: Record<string, SortOrder> = { [finalSortBy]: sortOrder };
if (finalSortBy !== 'updatedAt') {
sortObj.updatedAt = sortOrder;
if (finalSortBy !== secondaryField) {
sortObj[secondaryField] = sortOrder;
}
sortObj._id = sortOrder;
const convos = await Conversation.find(query)
.select(
'conversationId endpoint title createdAt updatedAt user model agent_id assistant_id spec iconURL chatProjectId pinned',
'conversationId endpoint title createdAt updatedAt archivedAt user model agent_id assistant_id spec iconURL chatProjectId pinned',
)
.sort(sortObj)
.limit(limit + 1)
@ -775,17 +869,26 @@ export function createConversationMethods(
if (convos.length > limit) {
convos.pop();
const lastReturned = convos[convos.length - 1];
let primaryValue: string | Date | undefined = lastReturned.updatedAt;
const sortValues: Record<string, string | Date | null | undefined> = {
title: lastReturned.title,
createdAt: lastReturned.createdAt,
updatedAt: lastReturned.updatedAt,
archivedAt: lastReturned.archivedAt,
};
const primaryValue = sortValues[finalSortBy];
/* A null primary is what tells the next page it is inside the missing-value
group, so an absent stamp has to survive as null rather than collapse to
the epoch, which would page from 1970 and replay the whole archive. */
let primaryStr: string | null = null;
if (finalSortBy === 'title') {
primaryValue = lastReturned.title;
} else if (finalSortBy === 'createdAt') {
primaryValue = lastReturned.createdAt;
primaryStr = typeof primaryValue === 'string' ? primaryValue : null;
} else if (primaryValue != null) {
primaryStr = new Date(primaryValue).toISOString();
}
const primaryStr =
finalSortBy === 'title'
? (primaryValue ?? null)
: new Date(primaryValue ?? 0).toISOString();
const secondaryStr = new Date(lastReturned.updatedAt ?? 0).toISOString();
const secondaryValueOut =
secondaryField === 'createdAt' ? lastReturned.createdAt : lastReturned.updatedAt;
const secondaryStr = new Date(secondaryValueOut ?? 0).toISOString();
const composite = {
primary: primaryStr,
secondary: secondaryStr,

View file

@ -52,6 +52,13 @@ const convoSchema: Schema<IConversation> = new Schema(
pinned: {
type: Boolean,
},
/**
* When the chat was filed away. Absent on conversations archived before this field
* existed, and on every unarchived one, so readers fall back to `createdAt`.
*/
archivedAt: {
type: Date,
},
},
{ timestamps: true },
);
@ -61,6 +68,9 @@ convoSchema.index({ createdAt: 1, updatedAt: 1 });
convoSchema.index({ conversationId: 1, user: 1, tenantId: 1 }, { unique: true });
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
* carries the legacy group, whose rows all share a missing `archivedAt`. */
convoSchema.index({ user: 1, isArchived: 1, archivedAt: -1, createdAt: -1, _id: -1 });
/** The sidebar's pinned section filters on user + pinned and pages by `updatedAt`. */
convoSchema.index({ user: 1, pinned: 1, updatedAt: -1, _id: -1 });

View file

@ -39,6 +39,8 @@ export interface IConversation extends Document {
instructions?: string;
stop?: string[];
isArchived?: boolean;
/** Set when archived, cleared on unarchive; absent on chats archived before it existed. */
archivedAt?: Date | null;
pinned?: boolean;
/** Derived per request from the shared-links collection; never persisted on the conversation. */
isShared?: boolean;