@@ -17,23 +17,13 @@ import {
1717} from '@/lib/api/list-query'
1818import { generateRequestId } from '@/lib/core/utils/request'
1919import { assertValidCustomToolDeclaration } from '@/lib/custom-tools/schema'
20+ import type { DbTransaction } from '@/lib/db/types'
2021
2122const logger = createLogger ( 'CustomToolsOperations' )
2223
2324export type CustomToolSortBy = 'title' | 'createdAt' | 'updatedAt'
2425
25- /**
26- * What `upsertCustomTools` does with a tool it would insert under a title the
27- * workspace already holds: `'error'` throws and rolls back the whole batch,
28- * `'skip'` leaves the existing row untouched and persists the rest.
29- */
30- type CustomToolTitleConflictPolicy = 'error' | 'skip'
31-
32- /**
33- * Internal function to create/update custom tools
34- * Can be called from API routes or internal services
35- */
36- export async function upsertCustomTools ( params : {
26+ interface CustomToolWriteParams {
3727 tools : Array < {
3828 id ?: string
3929 title : string
@@ -43,112 +33,162 @@ export async function upsertCustomTools(params: {
4333 workspaceId : string
4434 userId : string
4535 requestId ?: string
46- onTitleConflict ?: CustomToolTitleConflictPolicy
47- } ) {
48- const {
49- tools ,
50- workspaceId ,
51- userId ,
52- requestId = generateRequestId ( ) ,
53- onTitleConflict = 'error' ,
54- } = params
55-
56- /**
57- * Ahead of the transaction so a batch is rejected whole rather than landing
58- * the tools that preceded the unstorable one.
59- */
36+ }
37+
38+ /**
39+ * What a batch write does with a tool it would insert under a title the
40+ * workspace already holds: `'error'` throws and rolls back the whole batch ,
41+ * `'skip'` leaves the existing row untouched and writes the rest.
42+ */
43+ type CustomToolTitleConflictPolicy = 'error' | 'skip'
44+
45+ /**
46+ * Ahead of the transaction so a batch is rejected whole rather than landing
47+ * the tools that preceded the unstorable one.
48+ */
49+ function assertStorableBatch ( tools : CustomToolWriteParams [ 'tools' ] ) {
6050 for ( const tool of tools ) {
6151 assertValidCustomToolDeclaration ( tool . schema )
6252 }
53+ }
6354
64- return await db . transaction ( async ( tx ) => {
65- for ( const tool of tools ) {
66- const nowTime = new Date ( )
55+ /** Writes the batch inside `tx`, returning the titles skipped under the `'skip'` policy. */
56+ async function writeCustomTools (
57+ tx : DbTransaction ,
58+ params : CustomToolWriteParams ,
59+ onTitleConflict : CustomToolTitleConflictPolicy
60+ ) : Promise < string [ ] > {
61+ const { tools, workspaceId, userId, requestId = generateRequestId ( ) } = params
62+ const skippedTitles : string [ ] = [ ]
63+
64+ for ( const tool of tools ) {
65+ const nowTime = new Date ( )
6766
68- if ( tool . id ) {
69- const existingWorkspaceTool = await tx
70- . select ( )
71- . from ( customTools )
67+ if ( tool . id ) {
68+ const existingWorkspaceTool = await tx
69+ . select ( )
70+ . from ( customTools )
71+ . where ( and ( eq ( customTools . id , tool . id ) , eq ( customTools . workspaceId , workspaceId ) ) )
72+ . limit ( 1 )
73+
74+ if ( existingWorkspaceTool . length > 0 ) {
75+ await tx
76+ . update ( customTools )
77+ . set ( {
78+ title : tool . title ,
79+ schema : tool . schema ,
80+ code : tool . code ,
81+ updatedAt : nowTime ,
82+ } )
7283 . where ( and ( eq ( customTools . id , tool . id ) , eq ( customTools . workspaceId , workspaceId ) ) )
73- . limit ( 1 )
74-
75- if ( existingWorkspaceTool . length > 0 ) {
76- await tx
77- . update ( customTools )
78- . set ( {
79- title : tool . title ,
80- schema : tool . schema ,
81- code : tool . code ,
82- updatedAt : nowTime ,
83- } )
84- . where ( and ( eq ( customTools . id , tool . id ) , eq ( customTools . workspaceId , workspaceId ) ) )
85- continue
86- }
87-
88- const existingLegacyTool = await tx
89- . select ( )
90- . from ( customTools )
91- . where (
92- and (
93- eq ( customTools . id , tool . id ) ,
94- isNull ( customTools . workspaceId ) ,
95- eq ( customTools . userId , userId )
96- )
97- )
98- . limit ( 1 )
99-
100- if ( existingLegacyTool . length > 0 ) {
101- await tx
102- . update ( customTools )
103- . set ( {
104- title : tool . title ,
105- schema : tool . schema ,
106- code : tool . code ,
107- updatedAt : nowTime ,
108- } )
109- . where ( eq ( customTools . id , tool . id ) )
110-
111- logger . info ( `[${ requestId } ] Updated legacy tool ${ tool . id } ` )
112- continue
113- }
84+ continue
11485 }
11586
116- const duplicateTitle = await tx
87+ const existingLegacyTool = await tx
11788 . select ( )
11889 . from ( customTools )
119- . where ( and ( eq ( customTools . workspaceId , workspaceId ) , eq ( customTools . title , tool . title ) ) )
90+ . where (
91+ and (
92+ eq ( customTools . id , tool . id ) ,
93+ isNull ( customTools . workspaceId ) ,
94+ eq ( customTools . userId , userId )
95+ )
96+ )
12097 . limit ( 1 )
12198
122- if ( duplicateTitle . length > 0 ) {
123- if ( onTitleConflict === 'skip' ) {
124- logger . info ( `[${ requestId } ] Skipped custom tool "${ tool . title } ": title already exists` )
125- continue
126- }
127- throw new Error ( `A tool with the title "${ tool . title } " already exists in this workspace` )
99+ if ( existingLegacyTool . length > 0 ) {
100+ await tx
101+ . update ( customTools )
102+ . set ( {
103+ title : tool . title ,
104+ schema : tool . schema ,
105+ code : tool . code ,
106+ updatedAt : nowTime ,
107+ } )
108+ . where ( eq ( customTools . id , tool . id ) )
109+
110+ logger . info ( `[${ requestId } ] Updated legacy tool ${ tool . id } ` )
111+ continue
128112 }
113+ }
114+
115+ const row = {
116+ id : generateShortId ( ) ,
117+ workspaceId,
118+ userId,
119+ title : tool . title ,
120+ schema : tool . schema ,
121+ code : tool . code ,
122+ createdAt : nowTime ,
123+ updatedAt : nowTime ,
124+ }
129125
130- await tx . insert ( customTools ) . values ( {
131- id : generateShortId ( ) ,
132- workspaceId,
133- userId,
134- title : tool . title ,
135- schema : tool . schema ,
136- code : tool . code ,
137- createdAt : nowTime ,
138- updatedAt : nowTime ,
139- } )
126+ if ( onTitleConflict === 'skip' ) {
127+ /** A conflict-target insert, so a title a concurrent save just committed is skipped too. */
128+ const inserted = await tx
129+ . insert ( customTools )
130+ . values ( row )
131+ . onConflictDoNothing ( { target : [ customTools . workspaceId , customTools . title ] } )
132+ . returning ( { id : customTools . id } )
133+ if ( inserted . length === 0 ) {
134+ logger . info ( `[${ requestId } ] Skipped custom tool "${ tool . title } ": title already exists` )
135+ skippedTitles . push ( tool . title )
136+ }
137+ continue
140138 }
141139
140+ const duplicateTitle = await tx
141+ . select ( )
142+ . from ( customTools )
143+ . where ( and ( eq ( customTools . workspaceId , workspaceId ) , eq ( customTools . title , tool . title ) ) )
144+ . limit ( 1 )
145+
146+ if ( duplicateTitle . length > 0 ) {
147+ throw new Error ( `A tool with the title "${ tool . title } " already exists in this workspace` )
148+ }
149+
150+ await tx . insert ( customTools ) . values ( row )
151+ }
152+
153+ return skippedTitles
154+ }
155+
156+ /**
157+ * Internal function to create/update custom tools
158+ * Can be called from API routes or internal services
159+ */
160+ export async function upsertCustomTools ( params : CustomToolWriteParams ) {
161+ assertStorableBatch ( params . tools )
162+
163+ return await db . transaction ( async ( tx ) => {
164+ await writeCustomTools ( tx , params , 'error' )
165+
142166 const resultTools = await tx
143167 . select ( )
144168 . from ( customTools )
145- . where ( eq ( customTools . workspaceId , workspaceId ) )
169+ . where ( eq ( customTools . workspaceId , params . workspaceId ) )
146170 . orderBy ( desc ( customTools . createdAt ) )
147171
148172 return resultTools
149173 } )
150174}
151175
176+ /**
177+ * `upsertCustomTools` for writes that re-offer tools they may already have
178+ * stored (workflow saves and imports): a tool whose title the workspace already
179+ * holds is skipped, leaving that row untouched, instead of failing the batch.
180+ *
181+ * @returns The titles that were skipped.
182+ */
183+ export async function upsertCustomToolsSkippingTakenTitles (
184+ params : CustomToolWriteParams
185+ ) : Promise < { skippedTitles : string [ ] } > {
186+ assertStorableBatch ( params . tools )
187+
188+ const skippedTitles = await db . transaction ( ( tx ) => writeCustomTools ( tx , params , 'skip' ) )
189+ return { skippedTitles }
190+ }
191+
152192export async function listCustomTools ( params : { userId : string ; workspaceId ?: string } ) {
153193 const { userId, workspaceId } = params
154194 return workspaceId
0 commit comments