diff --git a/apps/sim/app/api/folders/[id]/duplicate/route.ts b/apps/sim/app/api/folders/[id]/duplicate/route.ts index 92bc5ed75..3cbaa1b43 100644 --- a/apps/sim/app/api/folders/[id]/duplicate/route.ts +++ b/apps/sim/app/api/folders/[id]/duplicate/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { workflow, workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { and, eq, isNull, min } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' @@ -37,7 +37,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: logger.info(`[${requestId}] Duplicating folder ${sourceFolderId} for user ${session.user.id}`) - // Verify the source folder exists const sourceFolder = await db .select() .from(workflowFolder) @@ -48,7 +47,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: throw new Error('Source folder not found') } - // Check if user has permission to access the source folder const userPermission = await getUserEntityPermissions( session.user.id, 'workspace', @@ -61,26 +59,51 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: const targetWorkspaceId = workspaceId || sourceFolder.workspaceId - // Step 1: Duplicate folder structure const { newFolderId, folderMapping } = await db.transaction(async (tx) => { const newFolderId = crypto.randomUUID() const now = new Date() + const targetParentId = parentId ?? sourceFolder.parentId + + const folderParentCondition = targetParentId + ? eq(workflowFolder.parentId, targetParentId) + : isNull(workflowFolder.parentId) + const workflowParentCondition = targetParentId + ? eq(workflow.folderId, targetParentId) + : isNull(workflow.folderId) + + const [[folderResult], [workflowResult]] = await Promise.all([ + tx + .select({ minSortOrder: min(workflowFolder.sortOrder) }) + .from(workflowFolder) + .where(and(eq(workflowFolder.workspaceId, targetWorkspaceId), folderParentCondition)), + tx + .select({ minSortOrder: min(workflow.sortOrder) }) + .from(workflow) + .where(and(eq(workflow.workspaceId, targetWorkspaceId), workflowParentCondition)), + ]) + + const minSortOrder = [folderResult?.minSortOrder, workflowResult?.minSortOrder].reduce< + number | null + >((currentMin, candidate) => { + if (candidate == null) return currentMin + if (currentMin == null) return candidate + return Math.min(currentMin, candidate) + }, null) + const sortOrder = minSortOrder != null ? minSortOrder - 1 : 0 - // Create the new root folder await tx.insert(workflowFolder).values({ id: newFolderId, userId: session.user.id, workspaceId: targetWorkspaceId, name, color: color || sourceFolder.color, - parentId: parentId || sourceFolder.parentId, - sortOrder: sourceFolder.sortOrder, + parentId: targetParentId, + sortOrder, isExpanded: false, createdAt: now, updatedAt: now, }) - // Recursively duplicate child folders const folderMapping = new Map([[sourceFolderId, newFolderId]]) await duplicateFolderStructure( tx, @@ -96,7 +119,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: return { newFolderId, folderMapping } }) - // Step 2: Duplicate workflows const workflowStats = await duplicateWorkflowsInFolderTree( sourceFolder.workspaceId, targetWorkspaceId, @@ -173,7 +195,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: } } -// Helper to recursively duplicate folder structure async function duplicateFolderStructure( tx: any, sourceFolderId: string, @@ -184,7 +205,6 @@ async function duplicateFolderStructure( timestamp: Date, folderMapping: Map ): Promise { - // Get all child folders const childFolders = await tx .select() .from(workflowFolder) @@ -195,7 +215,6 @@ async function duplicateFolderStructure( ) ) - // Create each child folder and recurse for (const childFolder of childFolders) { const newChildFolderId = crypto.randomUUID() folderMapping.set(childFolder.id, newChildFolderId) @@ -213,7 +232,6 @@ async function duplicateFolderStructure( updatedAt: timestamp, }) - // Recurse for this child's children await duplicateFolderStructure( tx, childFolder.id, @@ -227,7 +245,6 @@ async function duplicateFolderStructure( } } -// Helper to duplicate all workflows in a folder tree async function duplicateWorkflowsInFolderTree( sourceWorkspaceId: string, targetWorkspaceId: string, @@ -237,9 +254,7 @@ async function duplicateWorkflowsInFolderTree( ): Promise<{ total: number; succeeded: number; failed: number }> { const stats = { total: 0, succeeded: 0, failed: 0 } - // Process each folder in the mapping for (const [oldFolderId, newFolderId] of folderMapping.entries()) { - // Get workflows in this folder const workflowsInFolder = await db .select() .from(workflow) @@ -247,7 +262,6 @@ async function duplicateWorkflowsInFolderTree( stats.total += workflowsInFolder.length - // Duplicate each workflow for (const sourceWorkflow of workflowsInFolder) { try { await duplicateWorkflow({ diff --git a/apps/sim/app/api/folders/route.test.ts b/apps/sim/app/api/folders/route.test.ts index 89c0ad451..92f71796e 100644 --- a/apps/sim/app/api/folders/route.test.ts +++ b/apps/sim/app/api/folders/route.test.ts @@ -10,9 +10,14 @@ import { mockConsoleLogger, setupCommonApiMocks, } from '@sim/testing' +import { drizzleOrmMock } from '@sim/testing/mocks' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/audit/log', () => auditMock) +vi.mock('drizzle-orm', () => ({ + ...drizzleOrmMock, + min: vi.fn((field) => ({ type: 'min', field })), +})) interface CapturedFolderValues { name?: string @@ -24,29 +29,35 @@ interface CapturedFolderValues { } function createMockTransaction(mockData: { - selectData?: Array<{ id: string; [key: string]: unknown }> + selectResults?: Array> insertResult?: Array<{ id: string; [key: string]: unknown }> + onInsertValues?: (values: CapturedFolderValues) => void }) { - const { selectData = [], insertResult = [] } = mockData - return vi.fn().mockImplementation(async (callback: (tx: unknown) => Promise) => { + const { selectResults = [[], []], insertResult = [], onInsertValues } = mockData + return async (callback: (tx: unknown) => Promise) => { + const where = vi.fn() + for (const result of selectResults) { + where.mockReturnValueOnce(result) + } + where.mockReturnValue([]) + const tx = { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - orderBy: vi.fn().mockReturnValue({ - limit: vi.fn().mockReturnValue(selectData), - }), - }), + where, }), }), insert: vi.fn().mockReturnValue({ - values: vi.fn().mockReturnValue({ - returning: vi.fn().mockReturnValue(insertResult), + values: vi.fn().mockImplementation((values: CapturedFolderValues) => { + onInsertValues?.(values) + return { + returning: vi.fn().mockReturnValue(insertResult), + } }), }), } return await callback(tx) - }) + } } describe('Folders API Route', () => { @@ -257,25 +268,12 @@ describe('Folders API Route', () => { it('should create a new folder successfully', async () => { mockAuthenticatedUser() - mockTransaction.mockImplementationOnce(async (callback: any) => { - const tx = { - select: vi.fn().mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - orderBy: vi.fn().mockReturnValue({ - limit: vi.fn().mockReturnValue([]), // No existing folders - }), - }), - }), - }), - insert: vi.fn().mockReturnValue({ - values: vi.fn().mockReturnValue({ - returning: vi.fn().mockReturnValue([mockFolders[0]]), - }), - }), - } - return await callback(tx) - }) + mockTransaction.mockImplementationOnce( + createMockTransaction({ + selectResults: [[], []], + insertResult: [mockFolders[0]], + }) + ) const req = createMockRequest('POST', { name: 'New Test Folder', @@ -285,12 +283,11 @@ describe('Folders API Route', () => { const { POST } = await import('@/app/api/folders/route') const response = await POST(req) + const responseBody = await response.json() expect(response.status).toBe(200) - - const data = await response.json() - expect(data).toHaveProperty('folder') - expect(data.folder).toMatchObject({ + expect(responseBody).toHaveProperty('folder') + expect(responseBody.folder).toMatchObject({ id: 'folder-1', name: 'Test Folder 1', workspaceId: 'workspace-123', @@ -299,26 +296,17 @@ describe('Folders API Route', () => { it('should create folder with correct sort order', async () => { mockAuthenticatedUser() + let capturedValues: CapturedFolderValues | null = null - mockTransaction.mockImplementationOnce(async (callback: any) => { - const tx = { - select: vi.fn().mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - orderBy: vi.fn().mockReturnValue({ - limit: vi.fn().mockReturnValue([{ sortOrder: 5 }]), // Existing folder with sort order 5 - }), - }), - }), - }), - insert: vi.fn().mockReturnValue({ - values: vi.fn().mockReturnValue({ - returning: vi.fn().mockReturnValue([{ ...mockFolders[0], sortOrder: 6 }]), - }), - }), - } - return await callback(tx) - }) + mockTransaction.mockImplementationOnce( + createMockTransaction({ + selectResults: [[{ minSortOrder: 5 }], [{ minSortOrder: 2 }]], + insertResult: [{ ...mockFolders[0], sortOrder: 1 }], + onInsertValues: (values) => { + capturedValues = values + }, + }) + ) const req = createMockRequest('POST', { name: 'New Test Folder', @@ -332,8 +320,10 @@ describe('Folders API Route', () => { const data = await response.json() expect(data.folder).toMatchObject({ - sortOrder: 6, + sortOrder: 1, }) + expect(capturedValues).not.toBeNull() + expect(capturedValues!.sortOrder).toBe(1) }) it('should create subfolder with parent reference', async () => { @@ -341,7 +331,7 @@ describe('Folders API Route', () => { mockTransaction.mockImplementationOnce( createMockTransaction({ - selectData: [], // No existing folders + selectResults: [[], []], insertResult: [{ ...mockFolders[1] }], }) ) @@ -402,25 +392,12 @@ describe('Folders API Route', () => { mockAuthenticatedUser() mockGetUserEntityPermissions.mockResolvedValue('write') // Write permissions - mockTransaction.mockImplementationOnce(async (callback: any) => { - const tx = { - select: vi.fn().mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - orderBy: vi.fn().mockReturnValue({ - limit: vi.fn().mockReturnValue([]), // No existing folders - }), - }), - }), - }), - insert: vi.fn().mockReturnValue({ - values: vi.fn().mockReturnValue({ - returning: vi.fn().mockReturnValue([mockFolders[0]]), - }), - }), - } - return await callback(tx) - }) + mockTransaction.mockImplementationOnce( + createMockTransaction({ + selectResults: [[], []], + insertResult: [mockFolders[0]], + }) + ) const req = createMockRequest('POST', { name: 'Test Folder', @@ -440,25 +417,12 @@ describe('Folders API Route', () => { mockAuthenticatedUser() mockGetUserEntityPermissions.mockResolvedValue('admin') // Admin permissions - mockTransaction.mockImplementationOnce(async (callback: any) => { - const tx = { - select: vi.fn().mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - orderBy: vi.fn().mockReturnValue({ - limit: vi.fn().mockReturnValue([]), // No existing folders - }), - }), - }), - }), - insert: vi.fn().mockReturnValue({ - values: vi.fn().mockReturnValue({ - returning: vi.fn().mockReturnValue([mockFolders[0]]), - }), - }), - } - return await callback(tx) - }) + mockTransaction.mockImplementationOnce( + createMockTransaction({ + selectResults: [[], []], + insertResult: [mockFolders[0]], + }) + ) const req = createMockRequest('POST', { name: 'Test Folder', @@ -527,28 +491,15 @@ describe('Folders API Route', () => { let capturedValues: CapturedFolderValues | null = null - mockTransaction.mockImplementationOnce(async (callback: any) => { - const tx = { - select: vi.fn().mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - orderBy: vi.fn().mockReturnValue({ - limit: vi.fn().mockReturnValue([]), - }), - }), - }), - }), - insert: vi.fn().mockReturnValue({ - values: vi.fn().mockImplementation((values) => { - capturedValues = values - return { - returning: vi.fn().mockReturnValue([mockFolders[0]]), - } - }), - }), - } - return await callback(tx) - }) + mockTransaction.mockImplementationOnce( + createMockTransaction({ + selectResults: [[], []], + insertResult: [mockFolders[0]], + onInsertValues: (values) => { + capturedValues = values + }, + }) + ) const req = createMockRequest('POST', { name: ' Test Folder With Spaces ', @@ -567,28 +518,15 @@ describe('Folders API Route', () => { let capturedValues: CapturedFolderValues | null = null - mockTransaction.mockImplementationOnce(async (callback: any) => { - const tx = { - select: vi.fn().mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - orderBy: vi.fn().mockReturnValue({ - limit: vi.fn().mockReturnValue([]), - }), - }), - }), - }), - insert: vi.fn().mockReturnValue({ - values: vi.fn().mockImplementation((values) => { - capturedValues = values - return { - returning: vi.fn().mockReturnValue([mockFolders[0]]), - } - }), - }), - } - return await callback(tx) - }) + mockTransaction.mockImplementationOnce( + createMockTransaction({ + selectResults: [[], []], + insertResult: [mockFolders[0]], + onInsertValues: (values) => { + capturedValues = values + }, + }) + ) const req = createMockRequest('POST', { name: 'Test Folder', diff --git a/apps/sim/app/api/folders/route.ts b/apps/sim/app/api/folders/route.ts index 19aef4ca4..835231d31 100644 --- a/apps/sim/app/api/folders/route.ts +++ b/apps/sim/app/api/folders/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' -import { workflowFolder } from '@sim/db/schema' +import { workflow, workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, asc, desc, eq, isNull } from 'drizzle-orm' +import { and, asc, eq, isNull, min } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' @@ -87,19 +87,33 @@ export async function POST(request: NextRequest) { if (providedSortOrder !== undefined) { sortOrder = providedSortOrder } else { - const existingFolders = await tx - .select({ sortOrder: workflowFolder.sortOrder }) - .from(workflowFolder) - .where( - and( - eq(workflowFolder.workspaceId, workspaceId), - parentId ? eq(workflowFolder.parentId, parentId) : isNull(workflowFolder.parentId) - ) - ) - .orderBy(desc(workflowFolder.sortOrder)) - .limit(1) + const folderParentCondition = parentId + ? eq(workflowFolder.parentId, parentId) + : isNull(workflowFolder.parentId) + const workflowParentCondition = parentId + ? eq(workflow.folderId, parentId) + : isNull(workflow.folderId) - sortOrder = existingFolders.length > 0 ? existingFolders[0].sortOrder + 1 : 0 + const [[folderResult], [workflowResult]] = await Promise.all([ + tx + .select({ minSortOrder: min(workflowFolder.sortOrder) }) + .from(workflowFolder) + .where(and(eq(workflowFolder.workspaceId, workspaceId), folderParentCondition)), + tx + .select({ minSortOrder: min(workflow.sortOrder) }) + .from(workflow) + .where(and(eq(workflow.workspaceId, workspaceId), workflowParentCondition)), + ]) + + const minSortOrder = [folderResult?.minSortOrder, workflowResult?.minSortOrder].reduce< + number | null + >((currentMin, candidate) => { + if (candidate == null) return currentMin + if (currentMin == null) return candidate + return Math.min(currentMin, candidate) + }, null) + + sortOrder = minSortOrder != null ? minSortOrder - 1 : 0 } const [folder] = await tx diff --git a/apps/sim/app/api/workflows/route.test.ts b/apps/sim/app/api/workflows/route.test.ts new file mode 100644 index 000000000..ddef020dc --- /dev/null +++ b/apps/sim/app/api/workflows/route.test.ts @@ -0,0 +1,137 @@ +/** + * @vitest-environment node + */ +import { auditMock, createMockRequest, mockConsoleLogger, setupCommonApiMocks } from '@sim/testing' +import { drizzleOrmMock } from '@sim/testing/mocks' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockCheckSessionOrInternalAuth = vi.fn() +const mockGetUserEntityPermissions = vi.fn() +const mockDbSelect = vi.fn() +const mockDbInsert = vi.fn() +const mockWorkflowCreated = vi.fn() + +vi.mock('drizzle-orm', () => ({ + ...drizzleOrmMock, + min: vi.fn((field) => ({ type: 'min', field })), +})) + +vi.mock('@/lib/audit/log', () => auditMock) + +describe('Workflows API Route - POST ordering', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + + setupCommonApiMocks() + mockConsoleLogger() + + vi.stubGlobal('crypto', { + randomUUID: vi.fn().mockReturnValue('workflow-new-id'), + }) + + mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-123', + userName: 'Test User', + userEmail: 'test@example.com', + }) + mockGetUserEntityPermissions.mockResolvedValue('write') + + vi.doMock('@sim/db', () => ({ + db: { + select: (...args: unknown[]) => mockDbSelect(...args), + insert: (...args: unknown[]) => mockDbInsert(...args), + }, + })) + + vi.doMock('@/lib/auth/hybrid', () => ({ + checkSessionOrInternalAuth: (...args: unknown[]) => mockCheckSessionOrInternalAuth(...args), + })) + + vi.doMock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: (...args: unknown[]) => mockGetUserEntityPermissions(...args), + workspaceExists: vi.fn(), + })) + + vi.doMock('@/app/api/workflows/utils', () => ({ + verifyWorkspaceMembership: vi.fn(), + })) + + vi.doMock('@/lib/core/telemetry', () => ({ + PlatformEvents: { + workflowCreated: (...args: unknown[]) => mockWorkflowCreated(...args), + }, + })) + }) + + it('uses top insertion against mixed siblings (folders + workflows)', async () => { + const minResultsQueue: Array> = [ + [{ minOrder: 5 }], + [{ minOrder: 2 }], + ] + + mockDbSelect.mockImplementation(() => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockImplementation(() => Promise.resolve(minResultsQueue.shift() ?? [])), + }), + })) + + let insertedValues: Record | null = null + mockDbInsert.mockReturnValue({ + values: vi.fn().mockImplementation((values: Record) => { + insertedValues = values + return Promise.resolve(undefined) + }), + }) + + const req = createMockRequest('POST', { + name: 'New Workflow', + description: 'desc', + color: '#3972F6', + workspaceId: 'workspace-123', + folderId: null, + }) + + const { POST } = await import('@/app/api/workflows/route') + const response = await POST(req) + const data = await response.json() + expect(response.status).toBe(200) + expect(data.sortOrder).toBe(1) + expect(insertedValues).not.toBeNull() + expect(insertedValues?.sortOrder).toBe(1) + }) + + it('defaults to sortOrder 0 when there are no siblings', async () => { + const minResultsQueue: Array> = [[], []] + + mockDbSelect.mockImplementation(() => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockImplementation(() => Promise.resolve(minResultsQueue.shift() ?? [])), + }), + })) + + let insertedValues: Record | null = null + mockDbInsert.mockReturnValue({ + values: vi.fn().mockImplementation((values: Record) => { + insertedValues = values + return Promise.resolve(undefined) + }), + }) + + const req = createMockRequest('POST', { + name: 'New Workflow', + description: 'desc', + color: '#3972F6', + workspaceId: 'workspace-123', + folderId: null, + }) + + const { POST } = await import('@/app/api/workflows/route') + const response = await POST(req) + const data = await response.json() + expect(response.status).toBe(200) + expect(data.sortOrder).toBe(0) + expect(insertedValues?.sortOrder).toBe(0) + }) +}) diff --git a/apps/sim/app/api/workflows/route.ts b/apps/sim/app/api/workflows/route.ts index 003c9fc63..611d808cf 100644 --- a/apps/sim/app/api/workflows/route.ts +++ b/apps/sim/app/api/workflows/route.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { permissions, workflow } from '@sim/db/schema' +import { permissions, workflow, workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, asc, eq, inArray, isNull, min } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' @@ -162,12 +162,33 @@ export async function POST(req: NextRequest) { if (providedSortOrder !== undefined) { sortOrder = providedSortOrder } else { - const folderCondition = folderId ? eq(workflow.folderId, folderId) : isNull(workflow.folderId) - const [minResult] = await db - .select({ minOrder: min(workflow.sortOrder) }) - .from(workflow) - .where(and(eq(workflow.workspaceId, workspaceId), folderCondition)) - sortOrder = (minResult?.minOrder ?? 1) - 1 + const workflowParentCondition = folderId + ? eq(workflow.folderId, folderId) + : isNull(workflow.folderId) + const folderParentCondition = folderId + ? eq(workflowFolder.parentId, folderId) + : isNull(workflowFolder.parentId) + + const [[workflowMinResult], [folderMinResult]] = await Promise.all([ + db + .select({ minOrder: min(workflow.sortOrder) }) + .from(workflow) + .where(and(eq(workflow.workspaceId, workspaceId), workflowParentCondition)), + db + .select({ minOrder: min(workflowFolder.sortOrder) }) + .from(workflowFolder) + .where(and(eq(workflowFolder.workspaceId, workspaceId), folderParentCondition)), + ]) + + const minSortOrder = [workflowMinResult?.minOrder, folderMinResult?.minOrder].reduce< + number | null + >((currentMin, candidate) => { + if (candidate == null) return currentMin + if (currentMin == null) return candidate + return Math.min(currentMin, candidate) + }, null) + + sortOrder = minSortOrder != null ? minSortOrder - 1 : 0 } await db.insert(workflow).values({ diff --git a/apps/sim/hooks/queries/folders.test.ts b/apps/sim/hooks/queries/folders.test.ts new file mode 100644 index 000000000..9af6eaa5b --- /dev/null +++ b/apps/sim/hooks/queries/folders.test.ts @@ -0,0 +1,177 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockLogger, queryClient, useFolderStoreMock, useWorkflowRegistryMock } = vi.hoisted(() => ({ + mockLogger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + queryClient: { + cancelQueries: vi.fn().mockResolvedValue(undefined), + invalidateQueries: vi.fn().mockResolvedValue(undefined), + }, + useFolderStoreMock: Object.assign(vi.fn(), { + getState: vi.fn(), + setState: vi.fn(), + }), + useWorkflowRegistryMock: Object.assign(vi.fn(), { + getState: vi.fn(), + setState: vi.fn(), + }), +})) + +let folderState: { + folders: Record +} + +let workflowRegistryState: { + workflows: Record +} + +vi.mock('@sim/logger', () => ({ + createLogger: vi.fn(() => mockLogger), +})) + +vi.mock('@tanstack/react-query', () => ({ + keepPreviousData: {}, + useQuery: vi.fn(), + useQueryClient: vi.fn(() => queryClient), + useMutation: vi.fn((options) => options), +})) + +vi.mock('@/stores/folders/store', () => ({ + useFolderStore: useFolderStoreMock, +})) + +vi.mock('@/stores/workflows/registry/store', () => ({ + useWorkflowRegistry: useWorkflowRegistryMock, +})) + +vi.mock('@/hooks/queries/workflows', () => ({ + workflowKeys: { + list: (workspaceId: string | undefined) => ['workflows', 'list', workspaceId ?? ''], + }, +})) + +import { useCreateFolder, useDuplicateFolderMutation } from '@/hooks/queries/folders' + +function getOptimisticFolderByName(name: string) { + return Object.values(folderState.folders).find((folder: any) => folder.name === name) as + | { sortOrder: number } + | undefined +} + +describe('folder optimistic top insertion ordering', () => { + beforeEach(() => { + vi.clearAllMocks() + useFolderStoreMock.getState.mockImplementation(() => folderState) + useFolderStoreMock.setState.mockImplementation((updater: any) => { + if (typeof updater === 'function') { + const next = updater(folderState) + if (next) { + folderState = { ...folderState, ...next } + } + return + } + + folderState = { ...folderState, ...updater } + }) + useWorkflowRegistryMock.getState.mockImplementation(() => workflowRegistryState) + + folderState = { + folders: { + 'folder-parent-match': { + id: 'folder-parent-match', + name: 'Existing sibling folder', + userId: 'user-1', + workspaceId: 'ws-1', + parentId: 'parent-1', + color: '#808080', + isExpanded: false, + sortOrder: 5, + createdAt: new Date(), + updatedAt: new Date(), + }, + 'folder-other-parent': { + id: 'folder-other-parent', + name: 'Other parent folder', + userId: 'user-1', + workspaceId: 'ws-1', + parentId: 'parent-2', + color: '#808080', + isExpanded: false, + sortOrder: -100, + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + } + + workflowRegistryState = { + workflows: { + 'workflow-parent-match': { + id: 'workflow-parent-match', + name: 'Existing sibling workflow', + workspaceId: 'ws-1', + folderId: 'parent-1', + sortOrder: 2, + }, + 'workflow-other-parent': { + id: 'workflow-other-parent', + name: 'Other parent workflow', + workspaceId: 'ws-1', + folderId: 'parent-2', + sortOrder: -50, + }, + }, + } + }) + + it('creates folders at top of mixed non-root siblings', async () => { + const mutation = useCreateFolder() + + await mutation.onMutate({ + workspaceId: 'ws-1', + name: 'New child folder', + parentId: 'parent-1', + }) + + const optimisticFolder = getOptimisticFolderByName('New child folder') + expect(optimisticFolder).toBeDefined() + expect(optimisticFolder?.sortOrder).toBe(1) + }) + + it('duplicates folders at top of mixed non-root siblings', async () => { + const mutation = useDuplicateFolderMutation() + + await mutation.onMutate({ + workspaceId: 'ws-1', + id: 'folder-parent-match', + name: 'Duplicated child folder', + parentId: 'parent-1', + }) + + const optimisticFolder = getOptimisticFolderByName('Duplicated child folder') + expect(optimisticFolder).toBeDefined() + expect(optimisticFolder?.sortOrder).toBe(1) + }) + + it('uses source parent scope when duplicate parentId is undefined', async () => { + const mutation = useDuplicateFolderMutation() + + await mutation.onMutate({ + workspaceId: 'ws-1', + id: 'folder-parent-match', + name: 'Duplicated with inherited parent', + // parentId intentionally omitted to mirror duplicate fallback behavior + }) + + const optimisticFolder = getOptimisticFolderByName('Duplicated with inherited parent') as + | { parentId: string | null; sortOrder: number } + | undefined + expect(optimisticFolder).toBeDefined() + expect(optimisticFolder?.parentId).toBe('parent-1') + expect(optimisticFolder?.sortOrder).toBe(1) + }) +}) diff --git a/apps/sim/hooks/queries/folders.ts b/apps/sim/hooks/queries/folders.ts index 61289ed65..b369795ac 100644 --- a/apps/sim/hooks/queries/folders.ts +++ b/apps/sim/hooks/queries/folders.ts @@ -5,9 +5,11 @@ import { createOptimisticMutationHandlers, generateTempId, } from '@/hooks/queries/utils/optimistic-mutation' +import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order' import { workflowKeys } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' import type { WorkflowFolder } from '@/stores/folders/types' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('FolderQueries') @@ -133,40 +135,35 @@ function createFolderMutationHandlers, - workspaceId: string, - parentId: string | null | undefined -): number { - const siblingFolders = Object.values(folders).filter( - (f) => f.workspaceId === workspaceId && f.parentId === (parentId || null) - ) - return siblingFolders.reduce((max, f) => Math.max(max, f.sortOrder), -1) + 1 -} - export function useCreateFolder() { const queryClient = useQueryClient() const handlers = createFolderMutationHandlers( queryClient, 'CreateFolder', - (variables, tempId, previousFolders) => ({ - id: tempId, - name: variables.name, - userId: '', - workspaceId: variables.workspaceId, - parentId: variables.parentId || null, - color: variables.color || '#808080', - isExpanded: false, - sortOrder: - variables.sortOrder ?? - getNextSortOrder(previousFolders, variables.workspaceId, variables.parentId), - createdAt: new Date(), - updatedAt: new Date(), - }) + (variables, tempId, previousFolders) => { + const currentWorkflows = useWorkflowRegistry.getState().workflows + + return { + id: tempId, + name: variables.name, + userId: '', + workspaceId: variables.workspaceId, + parentId: variables.parentId || null, + color: variables.color || '#808080', + isExpanded: false, + sortOrder: + variables.sortOrder ?? + getTopInsertionSortOrder( + currentWorkflows, + previousFolders, + variables.workspaceId, + variables.parentId + ), + createdAt: new Date(), + updatedAt: new Date(), + } + } ) return useMutation({ @@ -242,17 +239,25 @@ export function useDuplicateFolderMutation() { queryClient, 'DuplicateFolder', (variables, tempId, previousFolders) => { + const currentWorkflows = useWorkflowRegistry.getState().workflows + // Get source folder info if available const sourceFolder = previousFolders[variables.id] + const targetParentId = variables.parentId ?? sourceFolder?.parentId ?? null return { id: tempId, name: variables.name, userId: sourceFolder?.userId || '', workspaceId: variables.workspaceId, - parentId: variables.parentId ?? sourceFolder?.parentId ?? null, + parentId: targetParentId, color: variables.color || sourceFolder?.color || '#808080', isExpanded: false, - sortOrder: getNextSortOrder(previousFolders, variables.workspaceId, variables.parentId), + sortOrder: getTopInsertionSortOrder( + currentWorkflows, + previousFolders, + variables.workspaceId, + targetParentId + ), createdAt: new Date(), updatedAt: new Date(), } diff --git a/apps/sim/hooks/queries/utils/top-insertion-sort-order.ts b/apps/sim/hooks/queries/utils/top-insertion-sort-order.ts new file mode 100644 index 000000000..fec629ca9 --- /dev/null +++ b/apps/sim/hooks/queries/utils/top-insertion-sort-order.ts @@ -0,0 +1,44 @@ +interface SortableWorkflow { + workspaceId?: string + folderId?: string | null + sortOrder?: number +} + +interface SortableFolder { + workspaceId?: string + parentId?: string | null + sortOrder: number +} + +/** + * Calculates the insertion sort order that places a new item at the top of a + * mixed list of folders and workflows within the same parent scope. + */ +export function getTopInsertionSortOrder( + workflows: Record, + folders: Record, + workspaceId: string, + parentId: string | null | undefined +): number { + const normalizedParentId = parentId ?? null + + const siblingWorkflows = Object.values(workflows).filter( + (workflow) => + workflow.workspaceId === workspaceId && (workflow.folderId ?? null) === normalizedParentId + ) + const siblingFolders = Object.values(folders).filter( + (folder) => + folder.workspaceId === workspaceId && (folder.parentId ?? null) === normalizedParentId + ) + + const siblingOrders = [ + ...siblingWorkflows.map((workflow) => workflow.sortOrder ?? 0), + ...siblingFolders.map((folder) => folder.sortOrder), + ] + + if (siblingOrders.length === 0) { + return 0 + } + + return Math.min(...siblingOrders) - 1 +} diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index 5e50194c4..9664cf864 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -8,6 +8,8 @@ import { createOptimisticMutationHandlers, generateTempId, } from '@/hooks/queries/utils/optimistic-mutation' +import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order' +import { useFolderStore } from '@/stores/folders/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils' @@ -223,11 +225,13 @@ export function useCreateWorkflow() { sortOrder = variables.sortOrder } else { const currentWorkflows = useWorkflowRegistry.getState().workflows - const targetFolderId = variables.folderId || null - const workflowsInFolder = Object.values(currentWorkflows).filter( - (w) => w.folderId === targetFolderId + const currentFolders = useFolderStore.getState().folders + sortOrder = getTopInsertionSortOrder( + currentWorkflows, + currentFolders, + variables.workspaceId, + variables.folderId ) - sortOrder = workflowsInFolder.reduce((min, w) => Math.min(min, w.sortOrder ?? 0), 1) - 1 } return { @@ -323,11 +327,8 @@ export function useDuplicateWorkflowMutation() { 'DuplicateWorkflow', (variables, tempId) => { const currentWorkflows = useWorkflowRegistry.getState().workflows - const targetFolderId = variables.folderId || null - const workflowsInFolder = Object.values(currentWorkflows).filter( - (w) => w.folderId === targetFolderId - ) - const minSortOrder = workflowsInFolder.reduce((min, w) => Math.min(min, w.sortOrder ?? 0), 1) + const currentFolders = useFolderStore.getState().folders + const targetFolderId = variables.folderId ?? null return { id: tempId, @@ -338,7 +339,12 @@ export function useDuplicateWorkflowMutation() { color: variables.color, workspaceId: variables.workspaceId, folderId: targetFolderId, - sortOrder: minSortOrder - 1, + sortOrder: getTopInsertionSortOrder( + currentWorkflows, + currentFolders, + variables.workspaceId, + targetFolderId + ), } } ) diff --git a/apps/sim/lib/workflows/persistence/duplicate.test.ts b/apps/sim/lib/workflows/persistence/duplicate.test.ts new file mode 100644 index 000000000..ec710ce10 --- /dev/null +++ b/apps/sim/lib/workflows/persistence/duplicate.test.ts @@ -0,0 +1,197 @@ +/** + * @vitest-environment node + */ +import { mockConsoleLogger, setupCommonApiMocks } from '@sim/testing' +import { drizzleOrmMock } from '@sim/testing/mocks' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockAuthorizeWorkflowByWorkspacePermission = vi.fn() +const mockGetUserEntityPermissions = vi.fn() + +const { mockDb } = vi.hoisted(() => ({ + mockDb: { + transaction: vi.fn(), + }, +})) + +vi.mock('drizzle-orm', () => ({ + ...drizzleOrmMock, + min: vi.fn((field) => ({ type: 'min', field })), +})) +vi.mock('@/lib/workflows/utils', () => ({ + authorizeWorkflowByWorkspacePermission: (...args: unknown[]) => + mockAuthorizeWorkflowByWorkspacePermission(...args), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: (...args: unknown[]) => mockGetUserEntityPermissions(...args), +})) + +vi.mock('@sim/db/schema', () => ({ + workflow: { + id: 'id', + workspaceId: 'workspaceId', + folderId: 'folderId', + sortOrder: 'sortOrder', + variables: 'variables', + }, + workflowFolder: { + workspaceId: 'workspaceId', + parentId: 'parentId', + sortOrder: 'sortOrder', + }, + workflowBlocks: { + workflowId: 'workflowId', + }, + workflowEdges: { + workflowId: 'workflowId', + }, + workflowSubflows: { + workflowId: 'workflowId', + }, +})) + +vi.mock('@sim/db', () => ({ + db: mockDb, +})) + +import { duplicateWorkflow } from './duplicate' + +function createMockTx( + selectResults: unknown[], + onWorkflowInsert?: (values: Record) => void +) { + let selectCallCount = 0 + + const select = vi.fn().mockImplementation(() => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockImplementation(() => { + const result = selectResults[selectCallCount++] ?? [] + if (selectCallCount === 1) { + return { + limit: vi.fn().mockResolvedValue(result), + } + } + return Promise.resolve(result) + }), + }), + })) + + const insert = vi.fn().mockReturnValue({ + values: vi.fn().mockImplementation((values: Record) => { + onWorkflowInsert?.(values) + return Promise.resolve(undefined) + }), + }) + + const update = vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }), + }) + + return { + select, + insert, + update, + } +} + +describe('duplicateWorkflow ordering', () => { + beforeEach(() => { + setupCommonApiMocks() + mockConsoleLogger() + vi.clearAllMocks() + + vi.stubGlobal('crypto', { + randomUUID: vi.fn().mockReturnValue('new-workflow-id'), + }) + + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: true }) + mockGetUserEntityPermissions.mockResolvedValue('write') + }) + + it('uses mixed-sibling top insertion sort order', async () => { + let insertedWorkflowValues: Record | null = null + const tx = createMockTx( + [ + [ + { + id: 'source-workflow-id', + workspaceId: 'workspace-123', + folderId: null, + description: 'source', + color: '#000000', + variables: {}, + }, + ], + [{ minOrder: 5 }], + [{ minOrder: 2 }], + [], + [], + [], + ], + (values) => { + insertedWorkflowValues = values + } + ) + + mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise) => + callback(tx) + ) + + const result = await duplicateWorkflow({ + sourceWorkflowId: 'source-workflow-id', + userId: 'user-123', + name: 'Duplicated', + workspaceId: 'workspace-123', + folderId: null, + requestId: 'req-1', + }) + + expect(result.sortOrder).toBe(1) + expect(insertedWorkflowValues?.sortOrder).toBe(1) + }) + + it('defaults to sortOrder 0 when target has no siblings', async () => { + let insertedWorkflowValues: Record | null = null + const tx = createMockTx( + [ + [ + { + id: 'source-workflow-id', + workspaceId: 'workspace-123', + folderId: null, + description: 'source', + color: '#000000', + variables: {}, + }, + ], + [], + [], + [], + [], + [], + ], + (values) => { + insertedWorkflowValues = values + } + ) + + mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise) => + callback(tx) + ) + + const result = await duplicateWorkflow({ + sourceWorkflowId: 'source-workflow-id', + userId: 'user-123', + name: 'Duplicated', + workspaceId: 'workspace-123', + folderId: null, + requestId: 'req-2', + }) + + expect(result.sortOrder).toBe(0) + expect(insertedWorkflowValues?.sortOrder).toBe(0) + }) +}) diff --git a/apps/sim/lib/workflows/persistence/duplicate.ts b/apps/sim/lib/workflows/persistence/duplicate.ts index 329d077e9..cee5f467a 100644 --- a/apps/sim/lib/workflows/persistence/duplicate.ts +++ b/apps/sim/lib/workflows/persistence/duplicate.ts @@ -1,5 +1,11 @@ import { db } from '@sim/db' -import { workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@sim/db/schema' +import { + workflow, + workflowBlocks, + workflowEdges, + workflowFolder, + workflowSubflows, +} from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull, min } from 'drizzle-orm' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' @@ -132,15 +138,31 @@ export async function duplicateWorkflow( throw new Error('Write or admin access required for target workspace') } const targetFolderId = folderId !== undefined ? folderId : source.folderId - const folderCondition = targetFolderId + const workflowParentCondition = targetFolderId ? eq(workflow.folderId, targetFolderId) : isNull(workflow.folderId) + const folderParentCondition = targetFolderId + ? eq(workflowFolder.parentId, targetFolderId) + : isNull(workflowFolder.parentId) - const [minResult] = await tx - .select({ minOrder: min(workflow.sortOrder) }) - .from(workflow) - .where(and(eq(workflow.workspaceId, targetWorkspaceId), folderCondition)) - const sortOrder = (minResult?.minOrder ?? 1) - 1 + const [[workflowMinResult], [folderMinResult]] = await Promise.all([ + tx + .select({ minOrder: min(workflow.sortOrder) }) + .from(workflow) + .where(and(eq(workflow.workspaceId, targetWorkspaceId), workflowParentCondition)), + tx + .select({ minOrder: min(workflowFolder.sortOrder) }) + .from(workflowFolder) + .where(and(eq(workflowFolder.workspaceId, targetWorkspaceId), folderParentCondition)), + ]) + const minSortOrder = [workflowMinResult?.minOrder, folderMinResult?.minOrder].reduce< + number | null + >((currentMin, candidate) => { + if (candidate == null) return currentMin + if (currentMin == null) return candidate + return Math.min(currentMin, candidate) + }, null) + const sortOrder = minSortOrder != null ? minSortOrder - 1 : 0 // Mapping from old variable IDs to new variable IDs (populated during variable duplication) const varIdMapping = new Map() diff --git a/bun.lock b/bun.lock index d46a5090c..bae82ffca 100644 --- a/bun.lock +++ b/bun.lock @@ -13,7 +13,7 @@ "glob": "13.0.0", "husky": "9.1.7", "lint-staged": "16.0.0", - "turbo": "2.8.9", + "turbo": "2.8.10", }, }, "apps/docs": { @@ -3437,19 +3437,19 @@ "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], - "turbo": ["turbo@2.8.9", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.9", "turbo-darwin-arm64": "2.8.9", "turbo-linux-64": "2.8.9", "turbo-linux-arm64": "2.8.9", "turbo-windows-64": "2.8.9", "turbo-windows-arm64": "2.8.9" }, "bin": { "turbo": "bin/turbo" } }, "sha512-G+Mq8VVQAlpz/0HTsxiNNk/xywaHGl+dk1oiBREgOEVCCDjXInDlONWUn5srRnC9s5tdHTFD1bx1N19eR4hI+g=="], + "turbo": ["turbo@2.8.10", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.10", "turbo-darwin-arm64": "2.8.10", "turbo-linux-64": "2.8.10", "turbo-linux-arm64": "2.8.10", "turbo-windows-64": "2.8.10", "turbo-windows-arm64": "2.8.10" }, "bin": { "turbo": "bin/turbo" } }, "sha512-OxbzDES66+x7nnKGg2MwBA1ypVsZoDTLHpeaP4giyiHSixbsiTaMyeJqbEyvBdp5Cm28fc+8GG6RdQtic0ijwQ=="], - "turbo-darwin-64": ["turbo-darwin-64@2.8.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-KnCw1ZI9KTnEAhdI9avZrnZ/z4wsM++flMA1w8s8PKOqi5daGpFV36qoPafg4S8TmYMe52JPWEoFr0L+lQ5JIw=="], + "turbo-darwin-64": ["turbo-darwin-64@2.8.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-A03fXh+B7S8mL3PbdhTd+0UsaGrhfyPkODvzBDpKRY7bbeac4MDFpJ7I+Slf2oSkCEeSvHKR7Z4U71uKRUfX7g=="], - "turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CbD5Y2NKJKBXTOZ7z7Cc7vGlFPZkYjApA7ri9lH4iFwKV1X7MoZswh9gyRLetXYWImVX1BqIvP8KftulJg/wIA=="], + "turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-sidzowgWL3s5xCHLeqwC9M3s9M0i16W1nuQF3Mc7fPHpZ+YPohvcbVFBB2uoRRHYZg6yBnwD4gyUHKTeXfwtXA=="], - "turbo-linux-64": ["turbo-linux-64@2.8.9", "", { "os": "linux", "cpu": "x64" }, "sha512-OXC9HdCtsHvyH+5KUoH8ds+p5WU13vdif0OPbsFzZca4cUXMwKA3HWwUuCgQetk0iAE4cscXpi/t8A263n3VTg=="], + "turbo-linux-64": ["turbo-linux-64@2.8.10", "", { "os": "linux", "cpu": "x64" }, "sha512-YK9vcpL3TVtqonB021XwgaQhY9hJJbKKUhLv16osxV0HkcQASQWUqR56yMge7puh6nxU67rQlTq1b7ksR1T3KA=="], - "turbo-linux-arm64": ["turbo-linux-arm64@2.8.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-yI5n8jNXiFA6+CxnXG0gO7h5ZF1+19K8uO3/kXPQmyl37AdiA7ehKJQOvf9OPAnmkGDHcF2HSCPltabERNRmug=="], + "turbo-linux-arm64": ["turbo-linux-arm64@2.8.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-3+j2tL0sG95iBJTm+6J8/45JsETQABPqtFyYjVjBbi6eVGdtNTiBmHNKrbvXRlQ3ZbUG75bKLaSSDHSEEN+btQ=="], - "turbo-windows-64": ["turbo-windows-64@2.8.9", "", { "os": "win32", "cpu": "x64" }, "sha512-/OztzeGftJAg258M/9vK2ZCkUKUzqrWXJIikiD2pm8TlqHcIYUmepDbyZSDfOiUjMy6NzrLFahpNLnY7b5vNgg=="], + "turbo-windows-64": ["turbo-windows-64@2.8.10", "", { "os": "win32", "cpu": "x64" }, "sha512-hdeF5qmVY/NFgiucf8FW0CWJWtyT2QPm5mIsX0W1DXAVzqKVXGq+Zf+dg4EUngAFKjDzoBeN6ec2Fhajwfztkw=="], - "turbo-windows-arm64": ["turbo-windows-arm64@2.8.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-xZ2VTwVTjIqpFZKN4UBxDHCPM3oJ2J5cpRzCBSmRpJ/Pn33wpiYjs+9FB2E03svKaD04/lSSLlEUej0UYsugfg=="], + "turbo-windows-arm64": ["turbo-windows-arm64@2.8.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-QGdr/Q8LWmj+ITMkSvfiz2glf0d7JG0oXVzGL3jxkGqiBI1zXFj20oqVY0qWi+112LO9SVrYdpHS0E/oGFrMbQ=="], "tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="], diff --git a/package.json b/package.json index 169f31272..0d4b6d78a 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "glob": "13.0.0", "husky": "9.1.7", "lint-staged": "16.0.0", - "turbo": "2.8.9" + "turbo": "2.8.10" }, "lint-staged": { "*.{js,jsx,ts,tsx,json,css,scss}": [