Skip to content

Commit 370642e

Browse files
waleedlatif1claude
andcommitted
perf(copilot): read chat transcripts from copilot_messages, not JSONB
Flip user-facing chat reads from the legacy copilot_chats.messages JSONB array (5.7GB, 99% TOAST) to the normalized copilot_messages table via a new loadCopilotChatMessages helper ordered by seq NULLS LAST, created_at, id — the verified canonical order. Both chat-detail getters (getAccessibleCopilotChat, getAccessibleCopilotChatWithMessages) now drop the messages column from their metadata select (no more whole-array detoast on every load) and assemble the transcript from the table after authorization. This cascades to the copilot + mothership GET endpoints and to resolveOrCreateChat's conversationHistory (the LLM payload). The normalize/effective-transcript pipeline is source-agnostic (copilot_messages.content == a JSONB array element), so transcripts are byte-identical. Dual-write and the JSONB column stay in place as the internal-logic source and fallback; removing JSONB writes is a later step. Prod integrity verified before cutover: 0 messages missing, 0 NULL-seq, 0 dup keys/seq, 0 orphans, order-parity vs JSONB = 0 mismatches. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 15ca66f commit 370642e

2 files changed

Lines changed: 175 additions & 11 deletions

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
vi.mock('@sim/db', () => dbChainMock)
8+
9+
const { mockAuthorizeWorkflow, mockGetActiveWorkflow } = vi.hoisted(() => ({
10+
mockAuthorizeWorkflow: vi.fn(),
11+
mockGetActiveWorkflow: vi.fn(),
12+
}))
13+
14+
vi.mock('@sim/workflow-authz', () => ({
15+
authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflow,
16+
getActiveWorkflowRecord: mockGetActiveWorkflow,
17+
}))
18+
19+
vi.mock('@/lib/workspaces/permissions/utils', () => ({
20+
assertActiveWorkspaceAccess: vi.fn(),
21+
checkWorkspaceAccess: vi.fn(),
22+
}))
23+
24+
import {
25+
getAccessibleCopilotChat,
26+
getAccessibleCopilotChatWithMessages,
27+
resolveOrCreateChat,
28+
} from '@/lib/copilot/chat/lifecycle'
29+
30+
const CHAT_ID = 'chat-1'
31+
const USER_ID = 'user-1'
32+
33+
// A chat with no workflow/workspace skips the authz lookups and authorizes directly.
34+
const chatRow = {
35+
id: CHAT_ID,
36+
userId: USER_ID,
37+
workflowId: null,
38+
workspaceId: null,
39+
type: 'copilot',
40+
title: 'Test',
41+
conversationId: null,
42+
resources: [],
43+
createdAt: new Date('2026-01-01T00:00:00.000Z'),
44+
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
45+
}
46+
47+
const userMsg = { id: 'm-user', role: 'user', content: 'Hi', timestamp: '2026-01-01T00:00:00.000Z' }
48+
const asstMsg = {
49+
id: 'm-asst',
50+
role: 'assistant',
51+
content: 'Hello',
52+
timestamp: '2026-01-01T00:00:01.000Z',
53+
}
54+
55+
describe('lifecycle copilot chat reads (cutover to copilot_messages)', () => {
56+
beforeEach(() => {
57+
vi.clearAllMocks()
58+
resetDbChainMock()
59+
})
60+
61+
it('getAccessibleCopilotChatWithMessages sources messages from copilot_messages in seq order', async () => {
62+
// 1st query: chat metadata (select().from().where().limit())
63+
dbChainMockFns.limit.mockResolvedValueOnce([chatRow])
64+
// 2nd query: messages (select().from().where().orderBy())
65+
dbChainMockFns.orderBy.mockResolvedValueOnce([{ content: userMsg }, { content: asstMsg }])
66+
67+
const result = await getAccessibleCopilotChatWithMessages(CHAT_ID, USER_ID)
68+
69+
expect(result).not.toBeNull()
70+
expect(result?.messages).toEqual([userMsg, asstMsg])
71+
expect(dbChainMockFns.orderBy).toHaveBeenCalledTimes(1)
72+
})
73+
74+
it('returns an empty transcript for a chat with no messages', async () => {
75+
dbChainMockFns.limit.mockResolvedValueOnce([chatRow])
76+
dbChainMockFns.orderBy.mockResolvedValueOnce([])
77+
78+
const result = await getAccessibleCopilotChatWithMessages(CHAT_ID, USER_ID)
79+
80+
expect(result?.messages).toEqual([])
81+
})
82+
83+
it('returns null and does NOT query messages when the chat is not found', async () => {
84+
dbChainMockFns.limit.mockResolvedValueOnce([])
85+
86+
const result = await getAccessibleCopilotChatWithMessages(CHAT_ID, USER_ID)
87+
88+
expect(result).toBeNull()
89+
expect(dbChainMockFns.orderBy).not.toHaveBeenCalled()
90+
})
91+
92+
it('legacy getAccessibleCopilotChat also assembles messages from copilot_messages', async () => {
93+
dbChainMockFns.limit.mockResolvedValueOnce([
94+
{ ...chatRow, model: 'm', planArtifact: null, config: null },
95+
])
96+
dbChainMockFns.orderBy.mockResolvedValueOnce([{ content: userMsg }])
97+
98+
const result = await getAccessibleCopilotChat(CHAT_ID, USER_ID)
99+
100+
expect(result?.messages).toEqual([userMsg])
101+
})
102+
103+
it('resolveOrCreateChat returns conversationHistory from the table for an existing chat', async () => {
104+
dbChainMockFns.limit.mockResolvedValueOnce([chatRow])
105+
dbChainMockFns.orderBy.mockResolvedValueOnce([{ content: userMsg }, { content: asstMsg }])
106+
107+
const result = await resolveOrCreateChat({ chatId: CHAT_ID, userId: USER_ID, model: 'm' })
108+
109+
expect(result.isNew).toBe(false)
110+
expect(result.conversationHistory).toEqual([userMsg, asstMsg])
111+
})
112+
113+
it('resolveOrCreateChat creates a new chat with an empty transcript', async () => {
114+
// insert().values().returning() -> fresh chat with empty messages
115+
dbChainMockFns.returning.mockResolvedValueOnce([{ ...chatRow, messages: [] }])
116+
117+
const result = await resolveOrCreateChat({ userId: USER_ID, model: 'm' })
118+
119+
expect(result.isNew).toBe(true)
120+
expect(result.conversationHistory).toEqual([])
121+
// a brand-new chat must not trigger a messages read
122+
expect(dbChainMockFns.orderBy).not.toHaveBeenCalled()
123+
})
124+
})

apps/sim/lib/copilot/chat/lifecycle.ts

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { db } from '@sim/db'
2-
import { copilotChats } from '@sim/db/schema'
2+
import { copilotChats, copilotMessages } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import {
55
authorizeWorkflowByWorkspacePermission,
66
getActiveWorkflowRecord,
77
} from '@sim/workflow-authz'
8-
import { and, eq } from 'drizzle-orm'
8+
import { and, asc, eq, isNull, sql } from 'drizzle-orm'
99
import {
1010
assertActiveWorkspaceAccess,
1111
checkWorkspaceAccess,
@@ -35,22 +35,33 @@ const copilotChatAuthColumns = {
3535
} as const
3636

3737
/**
38-
* Column set for chat-detail callers that need the conversation transcript but
39-
* not the copilot-only TOAST-able fields (`previewYaml`, `planArtifact`,
40-
* `config`) or unused metadata (`model`, `pinned`, `lastSeenAt`). Selecting
41-
* only these columns avoids the Postgres detoast cost on the dropped fields,
42-
* which dominates latency for chats with large message histories.
38+
* Column set for chat-detail callers that need chat metadata. The conversation
39+
* transcript is no longer selected from `copilot_chats.messages` (JSONB) —
40+
* reads now source it from the normalized `copilot_messages` table via
41+
* `loadCopilotChatMessages`, which avoids detoasting the large messages blob on
42+
* every load. The copilot-only TOAST-able fields (`previewYaml`,
43+
* `planArtifact`, `config`) and unused metadata (`model`, `pinned`,
44+
* `lastSeenAt`) remain excluded.
4345
*/
4446
const copilotChatDetailColumns = {
4547
...copilotChatAuthColumns,
4648
title: copilotChats.title,
47-
messages: copilotChats.messages,
4849
conversationId: copilotChats.conversationId,
4950
resources: copilotChats.resources,
5051
createdAt: copilotChats.createdAt,
5152
updatedAt: copilotChats.updatedAt,
5253
} as const
5354

55+
/**
56+
* Returning column set for newly-inserted chats. A fresh chat has no
57+
* `copilot_messages` rows yet, so the transcript is the just-inserted empty
58+
* JSONB array — return it directly rather than issuing a second query.
59+
*/
60+
const copilotChatDetailReturningColumns = {
61+
...copilotChatDetailColumns,
62+
messages: copilotChats.messages,
63+
} as const
64+
5465
/**
5566
* Column set for the legacy copilot chat detail endpoint. Extends
5667
* `copilotChatDetailColumns` with `model`, `planArtifact`, and `config` — the
@@ -64,6 +75,27 @@ const copilotChatLegacyDetailColumns = {
6475
config: copilotChats.config,
6576
} as const
6677

78+
/**
79+
* Load a chat's transcript from the normalized `copilot_messages` table in
80+
* canonical order (`seq` first, then `created_at`/`id` as a deterministic
81+
* tiebreak; `NULLS LAST` so any not-yet-sequenced row sorts after sequenced
82+
* ones). Each row's `content` is the full message object — identical in shape
83+
* to a legacy JSONB array element — so the downstream normalize/transcript
84+
* pipeline is unchanged.
85+
*/
86+
async function loadCopilotChatMessages(chatId: string): Promise<Record<string, unknown>[]> {
87+
const rows = await db
88+
.select({ content: copilotMessages.content })
89+
.from(copilotMessages)
90+
.where(and(eq(copilotMessages.chatId, chatId), isNull(copilotMessages.deletedAt)))
91+
.orderBy(
92+
sql`${copilotMessages.seq} asc nulls last`,
93+
asc(copilotMessages.createdAt),
94+
asc(copilotMessages.id)
95+
)
96+
return rows.map((row) => row.content as Record<string, unknown>)
97+
}
98+
6799
type CopilotChatAuthRow = Pick<
68100
typeof copilotChats.$inferSelect,
69101
'id' | 'userId' | 'workflowId' | 'workspaceId' | 'type'
@@ -160,7 +192,11 @@ export async function getAccessibleCopilotChat(
160192
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
161193
.limit(1)
162194

163-
return authorizeCopilotChatRow(chat, chatId, userId)
195+
const authorized = await authorizeCopilotChatRow(chat, chatId, userId)
196+
if (!authorized) return null
197+
198+
const messages = await loadCopilotChatMessages(chatId)
199+
return { ...authorized, messages }
164200
}
165201

166202
/**
@@ -181,7 +217,11 @@ export async function getAccessibleCopilotChatWithMessages(
181217
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
182218
.limit(1)
183219

184-
return authorizeCopilotChatRow(chat, chatId, userId)
220+
const authorized = await authorizeCopilotChatRow(chat, chatId, userId)
221+
if (!authorized) return null
222+
223+
const messages = await loadCopilotChatMessages(chatId)
224+
return { ...authorized, messages }
185225
}
186226

187227
/**
@@ -261,7 +301,7 @@ export async function resolveOrCreateChat(params: {
261301
messages: [],
262302
lastSeenAt: now,
263303
})
264-
.returning(copilotChatDetailColumns)
304+
.returning(copilotChatDetailReturningColumns)
265305

266306
if (!newChat) {
267307
logger.warn('Failed to create new copilot chat row', { userId, workflowId, workspaceId })

0 commit comments

Comments
 (0)