diff --git a/api/server/routes/__tests__/convos.spec.js b/api/server/routes/__tests__/convos.spec.js index ec39d8e947..5aba7dc6ee 100644 --- a/api/server/routes/__tests__/convos.spec.js +++ b/api/server/routes/__tests__/convos.spec.js @@ -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); diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index c52e4de47c..8da63304f8 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -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, diff --git a/client/src/components/Nav/SettingsTabs/General/ArchivedChatsTable.tsx b/client/src/components/Nav/SettingsTabs/General/ArchivedChatsTable.tsx index 0717b74094..6edd0eac78 100644 --- a/client/src/components/Nav/SettingsTabs/General/ArchivedChatsTable.tsx +++ b/client/src/components/Nav/SettingsTabs/General/ArchivedChatsTable.tsx @@ -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(['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; export default function ArchivedChatsTable() { @@ -79,7 +88,7 @@ export default function ArchivedChatsTable() { const sorting = useMemo( () => [ { - 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) => { 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, diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index e4b0841b3b..6eea1c2033 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -65,6 +65,7 @@ export const excludedKeys = new Set([ 'messages', 'isArchived', 'pinned', + 'archivedAt', 'tags', 'user', '__v', diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index c007fb05d2..3e8d73dad9 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -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(), diff --git a/packages/data-provider/src/types/queries.ts b/packages/data-provider/src/types/queries.ts index 4c2e2c9f28..8d95a94a91 100644 --- a/packages/data-provider/src/types/queries.ts +++ b/packages/data-provider/src/types/queries.ts @@ -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' diff --git a/packages/data-schemas/src/methods/conversation.spec.ts b/packages/data-schemas/src/methods/conversation.spec.ts index 1ddf9a0bbd..d83ccb0c8a 100644 --- a/packages/data-schemas/src/methods/conversation.spec.ts +++ b/packages/data-schemas/src/methods/conversation.spec.ts @@ -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 | Document[]> = []; + const writeSpy = jest + .spyOn(Conversation.collection, 'findOneAndUpdate') + .mockImplementation( + async ( + filter: Filter, + update: UpdateFilter | 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, + update: UpdateFilter | 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, + update: UpdateFilter | 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((resolve) => { + markArchiveWriteReached = resolve; + }); + let releaseArchiveWrite = () => {}; + const archiveWriteBlocked = new Promise((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, + update: UpdateFilter | 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(); + 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(); + 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', diff --git a/packages/data-schemas/src/methods/conversation.ts b/packages/data-schemas/src/methods/conversation.ts index 0c1a209be9..0cc7093d99 100644 --- a/packages/data-schemas/src/methods/conversation.ts +++ b/packages/data-schemas/src/methods/conversation.ts @@ -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; searchConversation(conversationId: string): Promise; @@ -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 = { $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) => { + const operation: Record = { $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, + operation: Record, + upsert: boolean, + ) => + Conversation.findOneAndUpdate(filter, operation, { + new: true, + upsert, + includeResultMetadata: true, + ...timestampOptions, + }) as unknown as Promise; + + 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 | 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[] = []; - /* 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 = { [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 = { + 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, diff --git a/packages/data-schemas/src/schema/convo.ts b/packages/data-schemas/src/schema/convo.ts index 682eb3c011..a9988c3e2b 100644 --- a/packages/data-schemas/src/schema/convo.ts +++ b/packages/data-schemas/src/schema/convo.ts @@ -52,6 +52,13 @@ const convoSchema: Schema = 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 }); diff --git a/packages/data-schemas/src/types/convo.ts b/packages/data-schemas/src/types/convo.ts index 3d0addd848..9652b85819 100644 --- a/packages/data-schemas/src/types/convo.ts +++ b/packages/data-schemas/src/types/convo.ts @@ -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;