Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 42 additions & 34 deletions apps/sim/app/api/workflows/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { listWorkflows, type WorkflowScope } from '@/lib/workflows/utils'
import { deduplicateWorkflowName, listWorkflows, type WorkflowScope } from '@/lib/workflows/utils'
import { getUserEntityPermissions, workspaceExists } from '@/lib/workspaces/permissions/utils'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'

Expand All @@ -25,6 +25,7 @@ const CreateWorkflowSchema = z.object({
workspaceId: z.string().optional(),
folderId: z.string().nullable().optional(),
sortOrder: z.number().int().optional(),
deduplicate: z.boolean().optional(),
})

// GET /api/workflows - Get workflows for user (optionally filtered by workspaceId)
Expand Down Expand Up @@ -126,12 +127,13 @@ export async function POST(req: NextRequest) {
const body = await req.json()
const {
id: clientId,
name,
name: requestedName,
description,
color,
workspaceId,
folderId,
sortOrder: providedSortOrder,
deduplicate,
} = CreateWorkflowSchema.parse(body)

if (!workspaceId) {
Expand Down Expand Up @@ -162,19 +164,6 @@ export async function POST(req: NextRequest) {

logger.info(`[${requestId}] Creating workflow ${workflowId} for user ${userId}`)

import('@/lib/core/telemetry')
.then(({ PlatformEvents }) => {
PlatformEvents.workflowCreated({
workflowId,
name,
workspaceId: workspaceId || undefined,
folderId: folderId || undefined,
})
})
.catch(() => {
// Silently fail
})

let sortOrder: number
if (providedSortOrder !== undefined) {
sortOrder = providedSortOrder
Expand Down Expand Up @@ -214,31 +203,50 @@ export async function POST(req: NextRequest) {
sortOrder = minSortOrder != null ? minSortOrder - 1 : 0
}

const duplicateConditions = [
eq(workflow.workspaceId, workspaceId),
isNull(workflow.archivedAt),
eq(workflow.name, name),
]
let name = requestedName

if (folderId) {
duplicateConditions.push(eq(workflow.folderId, folderId))
if (deduplicate) {
name = await deduplicateWorkflowName(requestedName, workspaceId, folderId)
} else {
Comment on lines 203 to 210
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Race condition between dedup check and insert

deduplicateWorkflowName computes a unique name with a DB read, but the actual insert happens several lines later with no transaction or lock. If two concurrent import requests check the same workspace+folder+name at the same time, both will receive the same "available" candidate (e.g. My Workflow (2)) and both will successfully insert it, resulting in duplicate names in the database.

For single-user sequential imports this is practically harmless, but it is theoretically possible when two browser tabs or API calls run simultaneously. Wrapping the dedup check and the db.insert(...) in a database transaction, or alternatively using an upsert/ON CONFLICT strategy, would eliminate the window.

This is worth keeping in mind, especially for the bulk workspace-import path where many workflows are created in a tight loop.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not an actual problem, db would correctly not do it

duplicateConditions.push(isNull(workflow.folderId))
}
const duplicateConditions = [
eq(workflow.workspaceId, workspaceId),
isNull(workflow.archivedAt),
eq(workflow.name, requestedName),
]

if (folderId) {
duplicateConditions.push(eq(workflow.folderId, folderId))
} else {
duplicateConditions.push(isNull(workflow.folderId))
}

const [duplicateWorkflow] = await db
.select({ id: workflow.id })
.from(workflow)
.where(and(...duplicateConditions))
.limit(1)
const [duplicateWorkflow] = await db
.select({ id: workflow.id })
.from(workflow)
.where(and(...duplicateConditions))
.limit(1)

if (duplicateWorkflow) {
return NextResponse.json(
{ error: `A workflow named "${name}" already exists in this folder` },
{ status: 409 }
)
if (duplicateWorkflow) {
return NextResponse.json(
{ error: `A workflow named "${requestedName}" already exists in this folder` },
{ status: 409 }
)
}
}

import('@/lib/core/telemetry')
.then(({ PlatformEvents }) => {
PlatformEvents.workflowCreated({
workflowId,
name,
workspaceId: workspaceId || undefined,
folderId: folderId || undefined,
})
})
.catch(() => {
// Silently fail
})

await db.insert(workflow).values({
id: workflowId,
userId,
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/workspace/[workspaceId]/home/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export function Home({ chatId }: HomeProps = {}) {
description,
color,
workspaceId,
deduplicate: true,
}),
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
workspaceId,
folderId,
sortOrder,
deduplicate: true,
}),
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
color: workflowColor,
workspaceId: newWorkspace.id,
folderId: targetFolderId,
deduplicate: true,
}),
})

Expand Down
5 changes: 4 additions & 1 deletion apps/sim/hooks/queries/workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ interface CreateWorkflowVariables {
folderId?: string | null
sortOrder?: number
id?: string
deduplicate?: boolean
}

interface CreateWorkflowResult {
Expand Down Expand Up @@ -300,7 +301,8 @@ export function useCreateWorkflow() {

return useMutation({
mutationFn: async (variables: CreateWorkflowVariables): Promise<CreateWorkflowResult> => {
const { workspaceId, name, description, color, folderId, sortOrder, id } = variables
const { workspaceId, name, description, color, folderId, sortOrder, id, deduplicate } =
variables

logger.info(`Creating new workflow in workspace: ${workspaceId}`)

Expand All @@ -315,6 +317,7 @@ export function useCreateWorkflow() {
workspaceId,
folderId: folderId || null,
sortOrder,
deduplicate,
}),
})

Expand Down
Loading