mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-20 17:13:44 +00:00
🗄️ 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:
parent
7857a99d63
commit
832bac39ad
10 changed files with 757 additions and 77 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue