Skip to content

Commit 727af41

Browse files
committed
fix(custom-tools): count skipped titles and skip title conflicts atomically
1 parent ab9e2ae commit 727af41

4 files changed

Lines changed: 154 additions & 102 deletions

File tree

‎apps/sim/lib/workflows/custom-tools/operations.ts‎

Lines changed: 133 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -17,23 +17,13 @@ import {
1717
} from '@/lib/api/list-query'
1818
import { generateRequestId } from '@/lib/core/utils/request'
1919
import { assertValidCustomToolDeclaration } from '@/lib/custom-tools/schema'
20+
import type { DbTransaction } from '@/lib/db/types'
2021

2122
const logger = createLogger('CustomToolsOperations')
2223

2324
export 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+
152192
export async function listCustomTools(params: { userId: string; workspaceId?: string }) {
153193
const { userId, workspaceId } = params
154194
return workspaceId

‎apps/sim/lib/workflows/persistence/custom-tools-persistence.integration.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ describe('extractAndPersistCustomTools', () => {
9494
owner
9595
)
9696

97-
expect(second.errors).toEqual([])
97+
expect(second).toEqual({ saved: 1, errors: [] })
9898
expect(await workspaceTools()).toEqual([
9999
{ title: 'Lookup order', code: 'return 1' },
100100
{ title: 'Refund order', code: 'return 2' },
@@ -114,9 +114,22 @@ describe('extractAndPersistCustomTools', () => {
114114
owner
115115
)
116116

117-
expect(result.errors).toEqual([])
117+
expect(result).toEqual({ saved: 0, errors: [] })
118118
expect(await workspaceTools()).toEqual([{ title: 'Lookup order', code: 'return "library"' }])
119119
})
120+
121+
it('lets concurrent saves of the same new inline tool both succeed with one row', async () => {
122+
const state = workflowStateWithInlineTools([{ title: 'Lookup order', code: 'return 1' }])
123+
124+
const results = await Promise.all([
125+
extractAndPersistCustomTools(state, workspaceId, owner),
126+
extractAndPersistCustomTools(state, workspaceId, owner),
127+
])
128+
129+
expect(results.map((result) => result.errors)).toEqual([[], []])
130+
expect(results.map((result) => result.saved).sort()).toEqual([0, 1])
131+
expect(await workspaceTools()).toEqual([{ title: 'Lookup order', code: 'return 1' }])
132+
})
120133
})
121134

122135
describe('upsertCustomTools', () => {

‎apps/sim/lib/workflows/persistence/custom-tools-persistence.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const { mockUpsertCustomTools } = vi.hoisted(() => ({
55
}))
66

77
vi.mock('@/lib/workflows/custom-tools/operations', () => ({
8-
upsertCustomTools: mockUpsertCustomTools,
8+
upsertCustomToolsSkippingTakenTitles: mockUpsertCustomTools,
99
}))
1010

1111
import { persistCustomToolsToDatabase } from '@/lib/workflows/persistence/custom-tools-persistence'
@@ -34,7 +34,7 @@ function customTool(overrides: Record<string, unknown> = {}) {
3434

3535
describe('persistCustomToolsToDatabase', () => {
3636
beforeEach(() => {
37-
mockUpsertCustomTools.mockResolvedValue([])
37+
mockUpsertCustomTools.mockResolvedValue({ skippedTitles: [] })
3838
})
3939

4040
it('skips a declaration missing the function discriminator the public API republishes', async () => {

‎apps/sim/lib/workflows/persistence/custom-tools-persistence.ts‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createLogger } from '@sim/logger'
22
import { toError } from '@sim/utils/errors'
33
import { isValidCustomToolDeclaration } from '@/lib/custom-tools/schema'
4-
import { upsertCustomTools } from '@/lib/workflows/custom-tools/operations'
4+
import { upsertCustomToolsSkippingTakenTitles } from '@/lib/workflows/custom-tools/operations'
55

66
const logger = createLogger('CustomToolsPersistence')
77

@@ -164,7 +164,7 @@ export async function persistCustomToolsToDatabase(
164164
}
165165

166166
try {
167-
await upsertCustomTools({
167+
const { skippedTitles } = await upsertCustomToolsSkippingTakenTitles({
168168
tools: validTools.map((tool) => ({
169169
id: tool.toolId,
170170
title: tool.title,
@@ -173,11 +173,10 @@ export async function persistCustomToolsToDatabase(
173173
})),
174174
workspaceId,
175175
userId,
176-
onTitleConflict: 'skip',
177176
})
178177

179-
saved = validTools.length
180-
logger.info(`Persisted ${saved} custom tool(s)`, { workspaceId })
178+
saved = validTools.length - skippedTitles.length
179+
logger.info(`Persisted ${saved} custom tool(s)`, { workspaceId, skippedTitles })
181180
} catch (error) {
182181
const errorMsg = `Failed to persist custom tools: ${toError(error).message}`
183182
logger.error(errorMsg, { error })

0 commit comments

Comments
 (0)