@@ -16,7 +16,7 @@ import {
1616 createNotFoundResponse ,
1717 createUnauthorizedResponse ,
1818} from '@/lib/copilot/request/http'
19- import type { ChatResource } from '@/lib/copilot/resources/persistence'
19+ import { type ChatResource , serializeChatResourceWrite } from '@/lib/copilot/resources/persistence'
2020import type { MothershipResourceUpdate } from '@/lib/copilot/resources/types'
2121import {
2222 canonicalizeDesktopSessionResource ,
@@ -55,44 +55,45 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
5555 return NextResponse . json ( { success : true } )
5656 }
5757
58- const [ chat ] = await db
59- . select ( { resources : copilotChats . resources } )
60- . from ( copilotChats )
61- . where (
62- and (
58+ const merged = await serializeChatResourceWrite ( chatId , ( ) =>
59+ db . transaction ( async ( tx ) => {
60+ const scope = and (
6361 eq ( copilotChats . id , chatId ) ,
6462 eq ( copilotChats . userId , userId ) ,
6563 isNull ( copilotChats . deletedAt )
6664 )
67- )
68- . limit ( 1 )
65+ const [ chat ] = await tx
66+ . select ( { resources : copilotChats . resources } )
67+ . from ( copilotChats )
68+ . where ( scope )
69+ . for ( 'update' )
70+ . limit ( 1 )
6971
70- if ( ! chat ) {
71- return createNotFoundResponse ( 'Chat not found or unauthorized' )
72- }
72+ if ( ! chat ) return null
7373
74- const existing = sanitizeChatResources (
75- Array . isArray ( chat . resources ) ? ( chat . resources as ChatResource [ ] ) : [ ]
76- )
77- const key = `${ resource . type } :${ resource . id } `
78- const prev = existing . find ( ( r ) => `${ r . type } :${ r . id } ` === key )
79-
80- const merged : ChatResource [ ] = prev
81- ? existing . map ( ( r ) =>
82- `${ r . type } :${ r . id } ` === key ? mergeChatResource ( r , resourceUpdate ) : r
74+ const existing = sanitizeChatResources (
75+ Array . isArray ( chat . resources ) ? ( chat . resources as ChatResource [ ] ) : [ ]
8376 )
84- : [ ...existing , mergeChatResource ( undefined , resourceUpdate ) ]
77+ const key = `${ resource . type } :${ resource . id } `
78+ const prev = existing . find ( ( r ) => `${ r . type } :${ r . id } ` === key )
79+ const next : ChatResource [ ] = prev
80+ ? existing . map ( ( r ) =>
81+ `${ r . type } :${ r . id } ` === key ? mergeChatResource ( r , resourceUpdate ) : r
82+ )
83+ : [ ...existing , mergeChatResource ( undefined , resourceUpdate ) ]
84+
85+ await tx
86+ . update ( copilotChats )
87+ . set ( { resources : sql `${ JSON . stringify ( next ) } ::jsonb` , updatedAt : new Date ( ) } )
88+ . where ( scope )
89+
90+ return next
91+ } )
92+ )
8593
86- await db
87- . update ( copilotChats )
88- . set ( { resources : sql `${ JSON . stringify ( merged ) } ::jsonb` , updatedAt : new Date ( ) } )
89- . where (
90- and (
91- eq ( copilotChats . id , chatId ) ,
92- eq ( copilotChats . userId , userId ) ,
93- isNull ( copilotChats . deletedAt )
94- )
95- )
94+ if ( ! merged ) {
95+ return createNotFoundResponse ( 'Chat not found or unauthorized' )
96+ }
9697
9798 logger . info ( 'Added resource to chat' , { chatId, resource } )
9899
@@ -122,41 +123,44 @@ export const PATCH = withRouteHandler(async (req: NextRequest) => {
122123 if ( ! parsed . success ) return parsed . response
123124 const { chatId, resources : newOrder } = parsed . data . body
124125
125- const [ chat ] = await db
126- . select ( { resources : copilotChats . resources } )
127- . from ( copilotChats )
128- . where (
129- and (
126+ const canonicalOrder = await serializeChatResourceWrite ( chatId , ( ) =>
127+ db . transaction ( async ( tx ) : Promise < ChatResource [ ] | null | undefined > => {
128+ const scope = and (
130129 eq ( copilotChats . id , chatId ) ,
131130 eq ( copilotChats . userId , userId ) ,
132131 isNull ( copilotChats . deletedAt )
133132 )
134- )
135- . limit ( 1 )
133+ const [ chat ] = await tx
134+ . select ( { resources : copilotChats . resources } )
135+ . from ( copilotChats )
136+ . where ( scope )
137+ . for ( 'update' )
138+ . limit ( 1 )
136139
137- if ( ! chat ) {
138- return createNotFoundResponse ( 'Chat not found or unauthorized' )
139- }
140+ if ( ! chat ) return undefined
140141
141- const existing = sanitizeChatResources (
142- Array . isArray ( chat . resources ) ? ( chat . resources as ChatResource [ ] ) : [ ]
142+ const existing = sanitizeChatResources (
143+ Array . isArray ( chat . resources ) ? ( chat . resources as ChatResource [ ] ) : [ ]
144+ )
145+ const next = reorderStoredChatResources ( existing , newOrder )
146+ if ( ! next ) return null
147+
148+ await tx
149+ . update ( copilotChats )
150+ . set ( { resources : sql `${ JSON . stringify ( next ) } ::jsonb` , updatedAt : new Date ( ) } )
151+ . where ( scope )
152+
153+ return next
154+ } )
143155 )
144- const canonicalOrder = reorderStoredChatResources ( existing , newOrder )
156+
157+ if ( canonicalOrder === undefined ) {
158+ return createNotFoundResponse ( 'Chat not found or unauthorized' )
159+ }
145160 if ( ! canonicalOrder ) {
146161 return createBadRequestResponse ( 'Reordered resources must match existing resources' )
147162 }
148163
149- await db
150- . update ( copilotChats )
151- . set ( { resources : sql `${ JSON . stringify ( canonicalOrder ) } ::jsonb` , updatedAt : new Date ( ) } )
152- . where (
153- and (
154- eq ( copilotChats . id , chatId ) ,
155- eq ( copilotChats . userId , userId ) ,
156- isNull ( copilotChats . deletedAt )
157- )
158- )
159-
160164 logger . info ( 'Reordered resources for chat' , { chatId, count : canonicalOrder . length } )
161165
162166 return NextResponse . json ( { success : true , resources : canonicalOrder } )
@@ -185,39 +189,44 @@ export const DELETE = withRouteHandler(async (req: NextRequest) => {
185189 if ( ! parsed . success ) return parsed . response
186190 const { chatId, resourceType, resourceId } = parsed . data . body
187191
188- // Old builds could persist an inner browser/terminal tab id. Closing the
189- // singleton panel removes every legacy row of that type so it cannot be
190- // canonicalized back into view on the next hydration.
191- const removePredicate =
192- resourceType === 'browser' || resourceType === 'terminal'
193- ? sql `elem->>'type' = ${ resourceType } `
194- : sql `elem->>'type' = ${ resourceType } AND elem->>'id' = ${ resourceId } `
195-
196- const [ updated ] = await db
197- . update ( copilotChats )
198- . set ( {
199- resources : sql `COALESCE((
200- SELECT jsonb_agg(elem)
201- FROM jsonb_array_elements(${ copilotChats . resources } ) elem
202- WHERE NOT (${ removePredicate } )
203- ), '[]'::jsonb)` ,
204- updatedAt : new Date ( ) ,
205- } )
206- . where (
207- and (
192+ const merged = await serializeChatResourceWrite ( chatId , ( ) =>
193+ db . transaction ( async ( tx ) => {
194+ const scope = and (
208195 eq ( copilotChats . id , chatId ) ,
209196 eq ( copilotChats . userId , userId ) ,
210197 isNull ( copilotChats . deletedAt )
211198 )
212- )
213- . returning ( { resources : copilotChats . resources } )
199+ const [ chat ] = await tx
200+ . select ( { resources : copilotChats . resources } )
201+ . from ( copilotChats )
202+ . where ( scope )
203+ . for ( 'update' )
204+ . limit ( 1 )
214205
215- if ( ! updated ) {
206+ if ( ! chat ) return null
207+
208+ const existing = sanitizeChatResources (
209+ Array . isArray ( chat . resources ) ? ( chat . resources as ChatResource [ ] ) : [ ]
210+ )
211+ const removeAllOfType = resourceType === 'browser' || resourceType === 'terminal'
212+ const next = existing . filter (
213+ ( resource ) =>
214+ resource . type !== resourceType || ( ! removeAllOfType && resource . id !== resourceId )
215+ )
216+
217+ await tx
218+ . update ( copilotChats )
219+ . set ( { resources : sql `${ JSON . stringify ( next ) } ::jsonb` , updatedAt : new Date ( ) } )
220+ . where ( scope )
221+
222+ return next
223+ } )
224+ )
225+
226+ if ( ! merged ) {
216227 return createNotFoundResponse ( 'Chat not found or unauthorized' )
217228 }
218229
219- const merged = Array . isArray ( updated . resources ) ? ( updated . resources as ChatResource [ ] ) : [ ]
220-
221230 logger . info ( 'Removed resource from chat' , { chatId, resourceType, resourceId } )
222231
223232 return NextResponse . json ( { success : true , resources : merged } )
0 commit comments