Skip to content

Commit 32daf69

Browse files
committed
improvement(copilot): make copilot_messages the sole transcript store, remove JSONB dual-write
Stop writing/reading the legacy copilot_chats.messages JSONB column now that reads are cut over to copilot_messages. Make appendCopilotChatMessages the primary write (throws on failure instead of swallowing), repoint peripheral readers (workspace VFS, chat cleanup, data drains, fork, superuser import) to copilot_messages, and persist the assistant turn inside finalizeAssistantTurn's transaction so it commits atomically with the stream-marker clear. The column itself is dropped in a follow-up migration after this bakes.
1 parent e8f6485 commit 32daf69

19 files changed

Lines changed: 592 additions & 678 deletions

File tree

apps/sim/app/api/copilot/chat/stop/route.test.ts

Lines changed: 53 additions & 143 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,19 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { authMockFns } from '@sim/testing'
4+
import { authMockFns, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
55
import { NextRequest } from 'next/server'
66
import { beforeEach, describe, expect, it, vi } from 'vitest'
77

8-
const {
9-
mockSelect,
10-
mockFrom,
11-
mockWhereSelect,
12-
mockLimit,
13-
mockForUpdate,
14-
mockUpdate,
15-
mockSet,
16-
mockWhereUpdate,
17-
mockReturning,
18-
mockPublishStatusChanged,
19-
mockSql,
20-
mockTransaction,
21-
} = vi.hoisted(() => {
22-
const mockSelect = vi.fn()
23-
const mockFrom = vi.fn()
24-
const mockWhereSelect = vi.fn()
25-
const mockLimit = vi.fn()
26-
const mockForUpdate = vi.fn()
27-
const mockUpdate = vi.fn()
28-
const mockSet = vi.fn()
29-
const mockWhereUpdate = vi.fn()
30-
const mockReturning = vi.fn()
31-
const mockPublishStatusChanged = vi.fn()
32-
const mockSql = vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({
33-
strings,
34-
values,
35-
}))
36-
const mockTransaction = vi.fn(
37-
(callback: (tx: { select: typeof mockSelect; update: typeof mockUpdate }) => unknown) =>
38-
callback({ select: mockSelect, update: mockUpdate })
39-
)
40-
41-
return {
42-
mockSelect,
43-
mockFrom,
44-
mockWhereSelect,
45-
mockLimit,
46-
mockForUpdate,
47-
mockUpdate,
48-
mockSet,
49-
mockWhereUpdate,
50-
mockReturning,
51-
mockPublishStatusChanged,
52-
mockSql,
53-
mockTransaction,
54-
}
55-
})
8+
vi.mock('@sim/db', () => dbChainMock)
569

57-
vi.mock('@sim/db/schema', () => ({
58-
copilotChats: {
59-
id: 'copilotChats.id',
60-
userId: 'copilotChats.userId',
61-
workspaceId: 'copilotChats.workspaceId',
62-
messages: 'copilotChats.messages',
63-
conversationId: 'copilotChats.conversationId',
64-
},
65-
}))
66-
67-
vi.mock('@sim/db', () => ({
68-
db: {
69-
transaction: mockTransaction,
70-
},
10+
const { mockAppendCopilotChatMessages, mockPublishStatusChanged } = vi.hoisted(() => ({
11+
mockAppendCopilotChatMessages: vi.fn(),
12+
mockPublishStatusChanged: vi.fn(),
7113
}))
7214

73-
vi.mock('drizzle-orm', () => ({
74-
and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })),
75-
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
76-
sql: mockSql,
15+
vi.mock('@/lib/copilot/chat/messages-store', () => ({
16+
appendCopilotChatMessages: mockAppendCopilotChatMessages,
7717
}))
7818

7919
vi.mock('@/lib/copilot/tasks', () => ({
@@ -92,81 +32,71 @@ function createRequest(body: Record<string, unknown>) {
9232
})
9333
}
9434

35+
/**
36+
* Sequence the two in-tx reads `finalizeAssistantTurn` issues: the chat row
37+
* (`FOR UPDATE ... LIMIT 1`) and the last-message lookup that drives dedup
38+
* (both terminate on `.limit(1)`).
39+
*/
40+
function mockReads(opts: {
41+
chat: Record<string, unknown> | null
42+
last?: { messageId: string; role: string }
43+
}) {
44+
dbChainMockFns.limit.mockResolvedValueOnce(opts.chat ? [opts.chat] : [])
45+
dbChainMockFns.limit.mockResolvedValueOnce(opts.last ? [opts.last] : [])
46+
}
47+
9548
describe('copilot chat stop route', () => {
9649
beforeEach(() => {
9750
vi.clearAllMocks()
98-
51+
// Drain the once-queue (clearAllMocks/resetDbChainMock don't), then restore defaults.
52+
dbChainMockFns.limit.mockReset()
53+
resetDbChainMock()
9954
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
100-
101-
mockLimit.mockResolvedValue([
102-
{
103-
workspaceId: 'ws-1',
104-
messages: [{ id: 'stream-1', role: 'user', content: 'hello' }],
105-
conversationId: 'stream-1',
106-
},
107-
])
108-
mockForUpdate.mockReturnValue({ limit: mockLimit })
109-
mockWhereSelect.mockReturnValue({ for: mockForUpdate })
110-
mockFrom.mockReturnValue({ where: mockWhereSelect })
111-
mockSelect.mockReturnValue({ from: mockFrom })
112-
113-
mockReturning.mockResolvedValue([{ workspaceId: 'ws-1' }])
114-
mockWhereUpdate.mockReturnValue({ returning: mockReturning })
115-
mockSet.mockReturnValue({ where: mockWhereUpdate })
116-
mockUpdate.mockReturnValue({ set: mockSet })
11755
})
11856

11957
it('returns 401 when unauthenticated', async () => {
12058
authMockFns.mockGetSession.mockResolvedValueOnce(null)
12159

12260
const response = await POST(
123-
createRequest({
124-
chatId: 'chat-1',
125-
streamId: 'stream-1',
126-
content: '',
127-
})
61+
createRequest({ chatId: 'chat-1', streamId: 'stream-1', content: '' })
12862
)
12963

13064
expect(response.status).toBe(401)
13165
expect(await response.json()).toEqual({ error: 'Unauthorized' })
13266
})
13367

13468
it('is a no-op when the chat is missing', async () => {
135-
mockLimit.mockResolvedValueOnce([])
69+
mockReads({ chat: null })
13670

13771
const response = await POST(
138-
createRequest({
139-
chatId: 'missing-chat',
140-
streamId: 'stream-1',
141-
content: '',
142-
})
72+
createRequest({ chatId: 'missing-chat', streamId: 'stream-1', content: '' })
14373
)
14474

14575
expect(response.status).toBe(200)
14676
expect(await response.json()).toEqual({ success: true })
147-
expect(mockUpdate).not.toHaveBeenCalled()
77+
expect(mockAppendCopilotChatMessages).not.toHaveBeenCalled()
14878
})
14979

15080
it('appends a stopped assistant message even with no content', async () => {
81+
mockReads({
82+
chat: { workspaceId: 'ws-1', conversationId: 'stream-1', model: null },
83+
last: { messageId: 'stream-1', role: 'user' },
84+
})
85+
15186
const response = await POST(
152-
createRequest({
153-
chatId: 'chat-1',
154-
streamId: 'stream-1',
155-
content: '',
156-
})
87+
createRequest({ chatId: 'chat-1', streamId: 'stream-1', content: '' })
15788
)
15889

15990
expect(response.status).toBe(200)
16091
expect(await response.json()).toEqual({ success: true })
16192

162-
const setArg = mockSet.mock.calls[0]?.[0]
163-
expect(setArg).toBeTruthy()
93+
const setArg = dbChainMockFns.set.mock.calls[0]?.[0] as Record<string, unknown>
16494
expect(setArg.conversationId).toBeNull()
165-
expect(setArg.messages).toBeTruthy()
95+
expect(Object.hasOwn(setArg, 'messages')).toBe(false)
16696

167-
const appendedPayload = JSON.parse(setArg.messages.values[1] as string)
168-
expect(appendedPayload).toHaveLength(1)
169-
expect(appendedPayload[0]).toMatchObject({
97+
expect(mockAppendCopilotChatMessages).toHaveBeenCalledTimes(1)
98+
const [, appended] = mockAppendCopilotChatMessages.mock.calls[0]
99+
expect(appended[0]).toMatchObject({
170100
role: 'assistant',
171101
content: '',
172102
contentBlocks: [{ type: 'complete', status: 'cancelled' }],
@@ -181,32 +111,21 @@ describe('copilot chat stop route', () => {
181111
})
182112

183113
it('appends a stopped assistant message if the stream marker was already cleared', async () => {
184-
mockLimit.mockResolvedValueOnce([
185-
{
186-
workspaceId: 'ws-1',
187-
messages: [{ id: 'stream-1', role: 'user', content: 'hello' }],
188-
conversationId: null,
189-
},
190-
])
114+
mockReads({
115+
chat: { workspaceId: 'ws-1', conversationId: null, model: null },
116+
last: { messageId: 'stream-1', role: 'user' },
117+
})
191118

192119
const response = await POST(
193-
createRequest({
194-
chatId: 'chat-1',
195-
streamId: 'stream-1',
196-
content: 'partial',
197-
})
120+
createRequest({ chatId: 'chat-1', streamId: 'stream-1', content: 'partial' })
198121
)
199122

200123
expect(response.status).toBe(200)
201124
expect(await response.json()).toEqual({ success: true })
202125

203-
const setArg = mockSet.mock.calls[0]?.[0]
204-
expect(setArg.messages).toBeTruthy()
205-
const appendedPayload = JSON.parse(setArg.messages.values[1] as string)
206-
expect(appendedPayload[0]).toMatchObject({
207-
role: 'assistant',
208-
content: 'partial',
209-
})
126+
expect(mockAppendCopilotChatMessages).toHaveBeenCalledTimes(1)
127+
const [, appended] = mockAppendCopilotChatMessages.mock.calls[0]
128+
expect(appended[0]).toMatchObject({ role: 'assistant', content: 'partial' })
210129

211130
expect(mockPublishStatusChanged).toHaveBeenCalledWith({
212131
workspaceId: 'ws-1',
@@ -217,28 +136,19 @@ describe('copilot chat stop route', () => {
217136
})
218137

219138
it('republishes completed status when the assistant was already persisted', async () => {
220-
mockLimit.mockResolvedValueOnce([
221-
{
222-
workspaceId: 'ws-1',
223-
messages: [
224-
{ id: 'stream-1', role: 'user', content: 'hello' },
225-
{ id: 'assistant-1', role: 'assistant', content: 'partial' },
226-
],
227-
conversationId: null,
228-
},
229-
])
139+
mockReads({
140+
chat: { workspaceId: 'ws-1', conversationId: null, model: null },
141+
last: { messageId: 'assistant-1', role: 'assistant' },
142+
})
230143

231144
const response = await POST(
232-
createRequest({
233-
chatId: 'chat-1',
234-
streamId: 'stream-1',
235-
content: 'partial',
236-
})
145+
createRequest({ chatId: 'chat-1', streamId: 'stream-1', content: 'partial' })
237146
)
238147

239148
expect(response.status).toBe(200)
240149
expect(await response.json()).toEqual({ success: true })
241-
expect(mockUpdate).not.toHaveBeenCalled()
150+
expect(mockAppendCopilotChatMessages).not.toHaveBeenCalled()
151+
expect(dbChainMockFns.set).not.toHaveBeenCalled()
242152
expect(mockPublishStatusChanged).toHaveBeenCalledWith({
243153
workspaceId: 'ws-1',
244154
chatId: 'chat-1',

apps/sim/app/api/copilot/chat/update-messages/route.test.ts

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const {
1616
mockSet,
1717
mockUpdateWhere,
1818
mockReturning,
19+
mockReplaceCopilotChatMessages,
1920
} = vi.hoisted(() => ({
2021
mockSelect: vi.fn(),
2122
mockFrom: vi.fn(),
@@ -25,15 +26,23 @@ const {
2526
mockSet: vi.fn(),
2627
mockUpdateWhere: vi.fn(),
2728
mockReturning: vi.fn(),
29+
mockReplaceCopilotChatMessages: vi.fn(),
2830
}))
2931

3032
vi.mock('@sim/db', () => ({
3133
db: {
3234
select: mockSelect,
3335
update: mockUpdate,
36+
transaction: async (
37+
cb: (tx: { update: typeof mockUpdate; select: typeof mockSelect }) => unknown
38+
) => cb({ update: mockUpdate, select: mockSelect }),
3439
},
3540
}))
3641

42+
vi.mock('@/lib/copilot/chat/messages-store', () => ({
43+
replaceCopilotChatMessages: mockReplaceCopilotChatMessages,
44+
}))
45+
3746
vi.mock('drizzle-orm', () => ({
3847
and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })),
3948
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
@@ -257,10 +266,13 @@ describe('Copilot Chat Update Messages API Route', () => {
257266

258267
expect(mockSelect).toHaveBeenCalled()
259268
expect(mockUpdate).toHaveBeenCalled()
260-
expect(mockSet).toHaveBeenCalledWith({
269+
expect(mockSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) })
270+
expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith(
271+
'chat-123',
261272
messages,
262-
updatedAt: expect.any(Date),
263-
})
273+
{ chatModel: 'gpt-4' },
274+
expect.anything()
275+
)
264276
})
265277

266278
it('should successfully update chat messages with optional fields', async () => {
@@ -315,8 +327,10 @@ describe('Copilot Chat Update Messages API Route', () => {
315327
messageCount: 2,
316328
})
317329

318-
expect(mockSet).toHaveBeenCalledWith({
319-
messages: [
330+
expect(mockSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) })
331+
expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith(
332+
'chat-456',
333+
[
320334
{
321335
id: 'msg-1',
322336
role: 'user',
@@ -345,8 +359,9 @@ describe('Copilot Chat Update Messages API Route', () => {
345359
],
346360
},
347361
],
348-
updatedAt: expect.any(Date),
349-
})
362+
{ chatModel: 'gpt-4' },
363+
expect.anything()
364+
)
350365
})
351366

352367
it('should handle empty messages array', async () => {
@@ -373,10 +388,13 @@ describe('Copilot Chat Update Messages API Route', () => {
373388
messageCount: 0,
374389
})
375390

376-
expect(mockSet).toHaveBeenCalledWith({
377-
messages: [],
378-
updatedAt: expect.any(Date),
379-
})
391+
expect(mockSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) })
392+
expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith(
393+
'chat-789',
394+
[],
395+
{ chatModel: 'gpt-4' },
396+
expect.anything()
397+
)
380398
})
381399

382400
it('should handle database errors during chat lookup', async () => {
@@ -485,10 +503,13 @@ describe('Copilot Chat Update Messages API Route', () => {
485503
messageCount: 100,
486504
})
487505

488-
expect(mockSet).toHaveBeenCalledWith({
506+
expect(mockSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) })
507+
expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith(
508+
'chat-large',
489509
messages,
490-
updatedAt: expect.any(Date),
491-
})
510+
{ chatModel: 'gpt-4' },
511+
expect.anything()
512+
)
492513
})
493514

494515
it('should handle messages with both user and assistant roles', async () => {

0 commit comments

Comments
 (0)