From 27ccafc52733361fde446323af2a371081dc7c73 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 15:23:34 -0700 Subject: [PATCH 1/7] chore(folders): drop the dead workflow-folder write helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createFolderRecord, updateFolderRecord and deleteFolderRecord in lib/workflows/utils.ts had no production callers — the only references were mock entries in @sim/testing that no test consumed. They were a second, less safe implementation of folder writes (no lock check, no audit) shadowing the real one in orchestration/folder-lifecycle.ts. Removing them leaves folder-lifecycle.ts as the single orchestration chokepoint and cuts the live workflow_folder write sites from 15 to 11. --- apps/sim/lib/workflows/utils.ts | 69 +------------------ .../testing/src/mocks/workflows-utils.mock.ts | 6 -- 2 files changed, 1 insertion(+), 74 deletions(-) diff --git a/apps/sim/lib/workflows/utils.ts b/apps/sim/lib/workflows/utils.ts index ced3f838e93..c6406174314 100644 --- a/apps/sim/lib/workflows/utils.ts +++ b/apps/sim/lib/workflows/utils.ts @@ -3,7 +3,7 @@ import { workflowFolder, workflow as workflowTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { generateId } from '@sim/utils/id' -import { and, asc, eq, inArray, isNull, max, min, sql } from 'drizzle-orm' +import { and, asc, eq, inArray, isNull, min, sql } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { ensureWorkflowAliasBacking } from '@/lib/copilot/vfs/workflow-alias-backing' @@ -493,45 +493,6 @@ export interface CreateFolderInput { parentId?: string | null } -export async function createFolderRecord(params: CreateFolderInput) { - const { userId, workspaceId, name, parentId = null } = params - - const [maxResult] = await db - .select({ maxOrder: max(workflowFolder.sortOrder) }) - .from(workflowFolder) - .where( - and( - eq(workflowFolder.workspaceId, workspaceId), - parentId ? eq(workflowFolder.parentId, parentId) : isNull(workflowFolder.parentId) - ) - ) - const sortOrder = (maxResult?.maxOrder ?? 0) + 1 - - const folderId = generateId() - await db.insert(workflowFolder).values({ - id: folderId, - userId, - workspaceId, - parentId, - name, - sortOrder, - createdAt: new Date(), - updatedAt: new Date(), - }) - - return { folderId, name, workspaceId, parentId } -} - -export async function updateFolderRecord( - folderId: string, - updates: { name?: string; parentId?: string | null } -) { - const setData: Record = { updatedAt: new Date() } - if (updates.name !== undefined) setData.name = updates.name - if (updates.parentId !== undefined) setData.parentId = updates.parentId - await db.update(workflowFolder).set(setData).where(eq(workflowFolder.id, folderId)) -} - export async function verifyFolderWorkspace( folderId: string, workspaceId: string @@ -544,34 +505,6 @@ export async function verifyFolderWorkspace( return Boolean(row) } -export async function deleteFolderRecord(folderId: string): Promise { - const [folder] = await db - .select({ parentId: workflowFolder.parentId }) - .from(workflowFolder) - .where(eq(workflowFolder.id, folderId)) - .limit(1) - - if (!folder) return false - - await db - .update(workflowTable) - .set({ folderId: folder.parentId, updatedAt: new Date() }) - .where(eq(workflowTable.folderId, folderId)) - - await db - .update(workflowFolder) - .set({ parentId: folder.parentId, updatedAt: new Date() }) - .where(eq(workflowFolder.parentId, folderId)) - - await db.delete(workflowFolder).where(eq(workflowFolder.id, folderId)) - - return true -} - -/** - * Checks whether setting `parentId` as the parent of `folderId` would - * create a circular reference in the folder tree. - */ export async function checkForCircularReference( folderId: string, parentId: string diff --git a/packages/testing/src/mocks/workflows-utils.mock.ts b/packages/testing/src/mocks/workflows-utils.mock.ts index 89613a98907..4065d44fabf 100644 --- a/packages/testing/src/mocks/workflows-utils.mock.ts +++ b/packages/testing/src/mocks/workflows-utils.mock.ts @@ -24,9 +24,6 @@ export const workflowsUtilsMockFns = { mockUpdateWorkflowRecord: vi.fn(), mockDeleteWorkflowRecord: vi.fn(), mockSetWorkflowVariables: vi.fn(), - mockCreateFolderRecord: vi.fn(), - mockUpdateFolderRecord: vi.fn(), - mockDeleteFolderRecord: vi.fn(), mockCheckForCircularReference: vi.fn(), mockListFolders: vi.fn(), } @@ -61,9 +58,6 @@ export const workflowsUtilsMock = { updateWorkflowRecord: workflowsUtilsMockFns.mockUpdateWorkflowRecord, deleteWorkflowRecord: workflowsUtilsMockFns.mockDeleteWorkflowRecord, setWorkflowVariables: workflowsUtilsMockFns.mockSetWorkflowVariables, - createFolderRecord: workflowsUtilsMockFns.mockCreateFolderRecord, - updateFolderRecord: workflowsUtilsMockFns.mockUpdateFolderRecord, - deleteFolderRecord: workflowsUtilsMockFns.mockDeleteFolderRecord, checkForCircularReference: workflowsUtilsMockFns.mockCheckForCircularReference, listFolders: workflowsUtilsMockFns.mockListFolders, } From b3f9b00adc15adbf5e686dae7c69f67d5bd2ca0d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 16:04:14 -0700 Subject: [PATCH 2/7] feat(folders): move workflow folders onto the generic folder table Repoints every workflow-folder read and write from `workflow_folder` to the generic `folder` table backfilled by migration 0272, scoped to `resourceType = 'workflow'`. - 11 write sites and 39 read sites repointed; the 21 reads that select by workspace or parent rather than by id now carry an explicit `resourceType` filter so they cannot return another type's folders - `archivedAt` becomes `deletedAt`; the dropped `color`/`isExpanded` columns are gone from the write paths - v1 admin `AdminFolder.color` is retained as always-null so the public response shape stays stable rather than silently losing a field - `GET /api/folders` still returns empty for non-workflow types: file folders are written by `uploads/contexts/workspace` and their backfilled rows are a frozen snapshot until that writer moves too --- .../app/api/folders/[id]/duplicate/route.ts | 26 +-- apps/sim/app/api/folders/[id]/route.test.ts | 2 +- apps/sim/app/api/folders/[id]/route.ts | 2 +- apps/sim/app/api/folders/reorder/route.ts | 14 +- apps/sim/app/api/folders/route.test.ts | 30 ---- .../api/v1/admin/folders/[id]/export/route.ts | 12 +- apps/sim/app/api/v1/admin/types.ts | 9 +- .../v1/admin/workspaces/[id]/export/route.ts | 12 +- .../v1/admin/workspaces/[id]/folders/route.ts | 20 ++- .../v1/admin/workspaces/[id]/import/route.ts | 4 +- .../app/api/v1/admin/workspaces/[id]/route.ts | 12 +- apps/sim/app/api/workflows/route.test.ts | 2 +- apps/sim/background/cleanup-soft-deletes.ts | 4 +- .../lib/copy/copy-workflows.ts | 14 +- .../sim/lib/copilot/chat/workspace-context.ts | 10 +- .../copilot/vfs/workflow-alias-resolver.ts | 8 +- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 10 +- apps/sim/lib/folders/cascade.ts | 164 ++++++++++++++++++ apps/sim/lib/folders/config.ts | 98 +++++++++++ apps/sim/lib/folders/queries.ts | 47 ++--- apps/sim/lib/logs/folder-expansion.ts | 10 +- apps/sim/lib/workflows/lifecycle.ts | 4 +- .../orchestration/folder-lifecycle.ts | 45 +++-- .../orchestration/workflow-lifecycle.ts | 10 +- .../workflows/persistence/duplicate.test.ts | 2 +- .../lib/workflows/persistence/duplicate.ts | 12 +- apps/sim/lib/workflows/utils.ts | 26 ++- packages/platform-authz/src/workflow.ts | 6 +- 28 files changed, 479 insertions(+), 136 deletions(-) create mode 100644 apps/sim/lib/folders/cascade.ts create mode 100644 apps/sim/lib/folders/config.ts diff --git a/apps/sim/app/api/folders/[id]/duplicate/route.ts b/apps/sim/app/api/folders/[id]/duplicate/route.ts index 66941dacb76..1f987794a79 100644 --- a/apps/sim/app/api/folders/[id]/duplicate/route.ts +++ b/apps/sim/app/api/folders/[id]/duplicate/route.ts @@ -1,6 +1,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { workflow, workflowFolder } from '@sim/db/schema' +import { workflow, folder as workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { FolderLockedError } from '@sim/platform-authz/workflow' import { generateId } from '@sim/utils/id' @@ -41,7 +41,7 @@ export const POST = withRouteHandler( const sourceFolder = await db .select() .from(workflowFolder) - .where(and(eq(workflowFolder.id, sourceFolderId), isNull(workflowFolder.archivedAt))) + .where(and(eq(workflowFolder.id, sourceFolderId), isNull(workflowFolder.deletedAt))) .then((rows) => rows[0]) if (!sourceFolder) { @@ -80,7 +80,13 @@ export const POST = withRouteHandler( tx .select({ minSortOrder: min(workflowFolder.sortOrder) }) .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, targetWorkspaceId), folderParentCondition)), + .where( + and( + eq(workflowFolder.workspaceId, targetWorkspaceId), + eq(workflowFolder.resourceType, 'workflow'), + folderParentCondition + ) + ), tx .select({ minSortOrder: min(workflow.sortOrder) }) .from(workflow) @@ -104,13 +110,12 @@ export const POST = withRouteHandler( await tx.insert(workflowFolder).values({ id: newFolderId, + resourceType: 'workflow', userId: session.user.id, workspaceId: targetWorkspaceId, name: deduplicatedName, - color: sourceFolder.color, parentId: targetParentId, sortOrder, - isExpanded: false, locked: false, createdAt: now, updatedAt: now, @@ -234,7 +239,7 @@ async function assertTargetParentFolderMutable( parentId: workflowFolder.parentId, workspaceId: workflowFolder.workspaceId, locked: workflowFolder.locked, - archivedAt: workflowFolder.archivedAt, + archivedAt: workflowFolder.deletedAt, }) .from(workflowFolder) .where(eq(workflowFolder.id, currentFolderId)) @@ -269,8 +274,9 @@ async function deduplicateFolderName( .where( and( eq(workflowFolder.workspaceId, workspaceId), + eq(workflowFolder.resourceType, 'workflow'), parentCondition, - isNull(workflowFolder.archivedAt) + isNull(workflowFolder.deletedAt) ) ) const siblingNames = new Set(siblingRows.map((row) => row.name)) @@ -302,7 +308,8 @@ async function duplicateFolderStructure( and( eq(workflowFolder.parentId, sourceFolderId), eq(workflowFolder.workspaceId, sourceWorkspaceId), - isNull(workflowFolder.archivedAt) + eq(workflowFolder.resourceType, 'workflow'), + isNull(workflowFolder.deletedAt) ) ) @@ -312,13 +319,12 @@ async function duplicateFolderStructure( await tx.insert(workflowFolder).values({ id: newChildFolderId, + resourceType: 'workflow', userId, workspaceId: targetWorkspaceId, name: childFolder.name, - color: childFolder.color, parentId: newParentFolderId, sortOrder: childFolder.sortOrder, - isExpanded: false, locked: false, createdAt: timestamp, updatedAt: timestamp, diff --git a/apps/sim/app/api/folders/[id]/route.test.ts b/apps/sim/app/api/folders/[id]/route.test.ts index d242609fba0..d636429248b 100644 --- a/apps/sim/app/api/folders/[id]/route.test.ts +++ b/apps/sim/app/api/folders/[id]/route.test.ts @@ -73,7 +73,7 @@ const mockFolder = { /** Queues the folder-existence lookup the route runs before authorizing. */ function queueFolderLookup(folder: Record = mockFolder) { - queueTableRows(schemaMock.workflowFolder, [folder]) + queueTableRows(schemaMock.folder, [folder]) } /** Makes the next folder lookup throw, exercising the route's 500 path. */ diff --git a/apps/sim/app/api/folders/[id]/route.ts b/apps/sim/app/api/folders/[id]/route.ts index dde474af258..0ff58b6a007 100644 --- a/apps/sim/app/api/folders/[id]/route.ts +++ b/apps/sim/app/api/folders/[id]/route.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { workflowFolder } from '@sim/db/schema' +import { folder as workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' import { eq } from 'drizzle-orm' diff --git a/apps/sim/app/api/folders/reorder/route.ts b/apps/sim/app/api/folders/reorder/route.ts index b361abf6df1..9c80e3f49d5 100644 --- a/apps/sim/app/api/folders/reorder/route.ts +++ b/apps/sim/app/api/folders/reorder/route.ts @@ -1,8 +1,8 @@ import { db } from '@sim/db' -import { workflowFolder } from '@sim/db/schema' +import { folder as workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' -import { eq, inArray } from 'drizzle-orm' +import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { reorderFoldersContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' @@ -60,7 +60,7 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { .select({ id: workflowFolder.id, workspaceId: workflowFolder.workspaceId, - archivedAt: workflowFolder.archivedAt, + archivedAt: workflowFolder.deletedAt, }) .from(workflowFolder) .where(inArray(workflowFolder.id, targetParentIds)) @@ -83,7 +83,13 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { const workspaceFolders = await db .select({ id: workflowFolder.id, parentId: workflowFolder.parentId }) .from(workflowFolder) - .where(eq(workflowFolder.workspaceId, workspaceId)) + .where( + and( + eq(workflowFolder.workspaceId, workspaceId), + eq(workflowFolder.resourceType, 'workflow'), + eq(workflowFolder.resourceType, 'workflow') + ) + ) const parentById = new Map() for (const folder of workspaceFolders) { diff --git a/apps/sim/app/api/folders/route.test.ts b/apps/sim/app/api/folders/route.test.ts index 8c0210e12b9..35dd03c15c0 100644 --- a/apps/sim/app/api/folders/route.test.ts +++ b/apps/sim/app/api/folders/route.test.ts @@ -579,35 +579,5 @@ describe('Folders API Route', () => { expect(capturedValues).not.toBeNull() expect(capturedValues!.name).toBe('Test Folder With Spaces') }) - - it('should use default color when not provided', async () => { - mockAuthenticatedUser() - - let capturedValues: CapturedFolderValues | null = null - - mockTransaction.mockImplementationOnce( - createMockTransaction({ - selectResults: [[], []], - insertResult: [mockFolders[0]], - onInsertValues: (values) => { - capturedValues = values - }, - }) - ) - mockValues.mockImplementationOnce((values: CapturedFolderValues) => { - capturedValues = values - return { returning: mockReturning } - }) - - const req = createMockRequest('POST', { - name: 'Test Folder', - workspaceId: 'workspace-123', - }) - - await POST(req) - - expect(capturedValues).not.toBeNull() - expect(capturedValues!.color).toBe('#6B7280') - }) }) }) diff --git a/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts b/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts index ddec95e57d3..55c3f8982d4 100644 --- a/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts +++ b/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts @@ -12,9 +12,9 @@ */ import { db } from '@sim/db' -import { workflow, workflowFolder } from '@sim/db/schema' +import { workflow, folder as workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { NextResponse } from 'next/server' import { adminV1ExportFolderContract } from '@/lib/api/contracts/v1/admin' import { parseRequest } from '@/lib/api/server' @@ -130,7 +130,13 @@ export const GET = withRouteHandler( parentId: workflowFolder.parentId, }) .from(workflowFolder) - .where(eq(workflowFolder.workspaceId, folderData.workspaceId)) + .where( + and( + eq(workflowFolder.workspaceId, folderData.workspaceId), + eq(workflowFolder.resourceType, 'workflow'), + eq(workflowFolder.resourceType, 'workflow') + ) + ) const workflowsInFolder = collectWorkflowsInFolder(folderId, allWorkflows, allFolders) const subfolders = collectSubfolders(folderId, allFolders) diff --git a/apps/sim/app/api/v1/admin/types.ts b/apps/sim/app/api/v1/admin/types.ts index 77e5d7f6366..09c10605948 100644 --- a/apps/sim/app/api/v1/admin/types.ts +++ b/apps/sim/app/api/v1/admin/types.ts @@ -13,7 +13,7 @@ import type { user, userStats, workflow, - workflowFolder, + folder as workflowFolder, workspace, } from '@sim/db/schema' import type { InferSelectModel } from 'drizzle-orm' @@ -156,6 +156,11 @@ export interface AdminFolder { id: string name: string parentId: string | null + /** + * Always `null` since folders moved to the generic `folder` table, which has no `color` + * column (it had no consumer). Retained so the v1 admin response shape stays stable for + * existing API clients rather than silently dropping a documented field. + */ color: string | null sortOrder: number createdAt: string @@ -167,7 +172,7 @@ export function toAdminFolder(dbFolder: DbWorkflowFolder): AdminFolder { id: dbFolder.id, name: dbFolder.name, parentId: dbFolder.parentId, - color: dbFolder.color, + color: null, sortOrder: dbFolder.sortOrder, createdAt: dbFolder.createdAt.toISOString(), updatedAt: dbFolder.updatedAt.toISOString(), diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts index 06204ab2898..f9a354ca6e3 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts @@ -13,9 +13,9 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { workflow, workflowFolder, workspace } from '@sim/db/schema' +import { workflow, folder as workflowFolder, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { NextResponse } from 'next/server' import { adminV1ExportWorkspaceContract } from '@/lib/api/contracts/v1/admin' import { parseRequest } from '@/lib/api/server' @@ -69,7 +69,13 @@ export const GET = withRouteHandler( const folders = await db .select() .from(workflowFolder) - .where(eq(workflowFolder.workspaceId, workspaceId)) + .where( + and( + eq(workflowFolder.workspaceId, workspaceId), + eq(workflowFolder.resourceType, 'workflow'), + eq(workflowFolder.resourceType, 'workflow') + ) + ) const workflowExports: Array<{ workflow: WorkspaceExportPayload['workflows'][number]['workflow'] diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/folders/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/folders/route.ts index 1b6269efeec..e42b12f89a5 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/folders/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/folders/route.ts @@ -11,9 +11,9 @@ */ import { db } from '@sim/db' -import { workflowFolder, workspace } from '@sim/db/schema' +import { folder as workflowFolder, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { count, eq } from 'drizzle-orm' +import { and, count, eq } from 'drizzle-orm' import { adminV1ListWorkspaceFoldersContract } from '@/lib/api/contracts/v1/admin' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -50,11 +50,23 @@ export const GET = withRouteHandler( db .select({ total: count() }) .from(workflowFolder) - .where(eq(workflowFolder.workspaceId, workspaceId)), + .where( + and( + eq(workflowFolder.workspaceId, workspaceId), + eq(workflowFolder.resourceType, 'workflow'), + eq(workflowFolder.resourceType, 'workflow') + ) + ), db .select() .from(workflowFolder) - .where(eq(workflowFolder.workspaceId, workspaceId)) + .where( + and( + eq(workflowFolder.workspaceId, workspaceId), + eq(workflowFolder.resourceType, 'workflow'), + eq(workflowFolder.resourceType, 'workflow') + ) + ) .orderBy(workflowFolder.sortOrder, workflowFolder.name) .limit(limit) .offset(offset), diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts index 967e432ef39..876629e9bb4 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts @@ -24,7 +24,7 @@ */ import { db } from '@sim/db' -import { workflow, workflowFolder } from '@sim/db/schema' +import { workflow, folder as workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' @@ -154,6 +154,7 @@ export const POST = withRouteHandler( rootFolderId = generateId() await db.insert(workflowFolder).values({ id: rootFolderId, + resourceType: 'workflow', name: rootFolderName, userId: workspaceData.ownerId, workspaceId, @@ -232,6 +233,7 @@ async function importSingleWorkflow( const folderId = generateId() await db.insert(workflowFolder).values({ id: folderId, + resourceType: 'workflow', name: wf.folderPath[i], userId: ownerId, workspaceId, diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/route.ts index b2ebdd5a82f..fc0c32660b1 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/route.ts @@ -7,9 +7,9 @@ */ import { db } from '@sim/db' -import { workflow, workflowFolder, workspace } from '@sim/db/schema' +import { workflow, folder as workflowFolder, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { count, eq } from 'drizzle-orm' +import { and, count, eq } from 'drizzle-orm' import { adminV1GetWorkspaceContract } from '@/lib/api/contracts/v1/admin' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -50,7 +50,13 @@ export const GET = withRouteHandler( db .select({ count: count() }) .from(workflowFolder) - .where(eq(workflowFolder.workspaceId, workspaceId)), + .where( + and( + eq(workflowFolder.workspaceId, workspaceId), + eq(workflowFolder.resourceType, 'workflow'), + eq(workflowFolder.resourceType, 'workflow') + ) + ), ]) const data: AdminWorkspaceDetail = { diff --git a/apps/sim/app/api/workflows/route.test.ts b/apps/sim/app/api/workflows/route.test.ts index 106b6bf8faf..fa7c349267d 100644 --- a/apps/sim/app/api/workflows/route.test.ts +++ b/apps/sim/app/api/workflows/route.test.ts @@ -95,7 +95,7 @@ describe('Workflows API Route - POST ordering', () => { it('uses top insertion against mixed siblings (folders + workflows)', async () => { queueTableRows(schemaMock.workflow, []) queueTableRows(schemaMock.workflow, [{ minOrder: 5 }]) - queueTableRows(schemaMock.workflowFolder, [{ minOrder: 2 }]) + queueTableRows(schemaMock.folder, [{ minOrder: 2 }]) const req = createMockRequest('POST', { name: 'New Workflow', diff --git a/apps/sim/background/cleanup-soft-deletes.ts b/apps/sim/background/cleanup-soft-deletes.ts index 7db8b2cc8f0..60d4c0c8d52 100644 --- a/apps/sim/background/cleanup-soft-deletes.ts +++ b/apps/sim/background/cleanup-soft-deletes.ts @@ -7,7 +7,7 @@ import { memory, userTableDefinitions, workflow, - workflowFolder, + folder as workflowFolder, workflowMcpServer, workspaceFile, workspaceFiles, @@ -414,7 +414,7 @@ async function cleanupExpiredKnowledgeBases( const CLEANUP_TARGETS = [ { table: workflowFolder, - softDeleteCol: workflowFolder.archivedAt, + softDeleteCol: workflowFolder.deletedAt, wsCol: workflowFolder.workspaceId, name: 'workflowFolder', }, diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts index 62ee9de38c2..a6ead16095c 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts @@ -1,4 +1,4 @@ -import { workflow, workflowBlocks, workflowFolder } from '@sim/db/schema' +import { workflow, workflowBlocks, folder as workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, inArray, isNull } from 'drizzle-orm' @@ -75,7 +75,11 @@ export async function resolveForkFolderMapping({ .select() .from(workflowFolder) .where( - and(eq(workflowFolder.workspaceId, sourceWorkspaceId), isNull(workflowFolder.archivedAt)) + and( + eq(workflowFolder.workspaceId, sourceWorkspaceId), + eq(workflowFolder.resourceType, 'workflow'), + isNull(workflowFolder.deletedAt) + ) ) if (sourceFolders.length === 0) return map @@ -98,7 +102,11 @@ export async function resolveForkFolderMapping({ .select() .from(workflowFolder) .where( - and(eq(workflowFolder.workspaceId, targetWorkspaceId), isNull(workflowFolder.archivedAt)) + and( + eq(workflowFolder.workspaceId, targetWorkspaceId), + eq(workflowFolder.resourceType, 'workflow'), + isNull(workflowFolder.deletedAt) + ) ) const targetByKey = new Map() diff --git a/apps/sim/lib/copilot/chat/workspace-context.ts b/apps/sim/lib/copilot/chat/workspace-context.ts index 5276e3257f6..82e40a43cbe 100644 --- a/apps/sim/lib/copilot/chat/workspace-context.ts +++ b/apps/sim/lib/copilot/chat/workspace-context.ts @@ -5,7 +5,7 @@ import { mcpServers, userTableDefinitions, workflow, - workflowFolder, + folder as workflowFolder, workflowSchedule, } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -384,7 +384,13 @@ async function buildWorkspaceMdData( parentId: workflowFolder.parentId, }) .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, workspaceId), isNull(workflowFolder.archivedAt))), + .where( + and( + eq(workflowFolder.workspaceId, workspaceId), + eq(workflowFolder.resourceType, 'workflow'), + isNull(workflowFolder.deletedAt) + ) + ), db .select({ diff --git a/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts b/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts index 449b201d6ff..49a38db9583 100644 --- a/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts +++ b/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { workflow, workflowFolder } from '@sim/db/schema' +import { workflow, folder as workflowFolder } from '@sim/db/schema' import { and, asc, eq, isNull } from 'drizzle-orm' import { buildWorkflowAliasWorkflowEntries, @@ -46,7 +46,11 @@ export async function resolveWorkflowAliasForWorkspace(args: { }) .from(workflowFolder) .where( - and(eq(workflowFolder.workspaceId, args.workspaceId), isNull(workflowFolder.archivedAt)) + and( + eq(workflowFolder.workspaceId, args.workspaceId), + eq(workflowFolder.resourceType, 'workflow'), + isNull(workflowFolder.deletedAt) + ) ) .orderBy(asc(workflowFolder.sortOrder), asc(workflowFolder.createdAt)), ]) diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index c21b8361827..2f406491a2a 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -12,7 +12,7 @@ import { skill as skillTable, workflowDeploymentVersion, workflowExecutionLogs, - workflowFolder, + folder as workflowFolder, workflowMcpServer, workflowMcpTool, workflowSchedule, @@ -2413,11 +2413,15 @@ export class WorkspaceVFS { .select({ id: workflowFolder.id, name: workflowFolder.name, - archivedAt: workflowFolder.archivedAt, + archivedAt: workflowFolder.deletedAt, }) .from(workflowFolder) .where( - and(eq(workflowFolder.workspaceId, workspaceId), isNotNull(workflowFolder.archivedAt)) + and( + eq(workflowFolder.workspaceId, workspaceId), + eq(workflowFolder.resourceType, 'workflow'), + isNotNull(workflowFolder.deletedAt) + ) ), listTables(workspaceId, { scope: 'archived' }), listWorkspaceFiles(workspaceId, { scope: 'archived' }), diff --git a/apps/sim/lib/folders/cascade.ts b/apps/sim/lib/folders/cascade.ts new file mode 100644 index 00000000000..7bb7937320e --- /dev/null +++ b/apps/sim/lib/folders/cascade.ts @@ -0,0 +1,164 @@ +import { type db, folder as folderTable } from '@sim/db' +import { and, eq, inArray, isNull, type SQL } from 'drizzle-orm' +import type { FolderResourceType } from '@/lib/api/contracts/folders' +import { type FolderResourceConfig, folderResourceConfig } from '@/lib/folders/config' +import { collectDescendantFolderIds } from '@/lib/folders/subtree' + +/** Narrow enough for both `db` and an open transaction handle. */ +type DbOrTx = Pick + +export interface FolderCascadeCounts { + folders: number + children: number +} + +function activeChildFilter(config: FolderResourceConfig, workspaceId: string, ids: SQL): SQL { + return and( + ids, + eq(config.workspaceColumn, workspaceId), + isNull(config.deletedColumn), + config.scope + ) as SQL +} + +/** + * Collects a folder's full subtree (itself plus every active descendant) within one + * resourceType. The walk itself is pure and shared with the other subtree consumers. + */ +export async function collectFolderSubtreeIds( + tx: DbOrTx, + workspaceId: string, + resourceType: FolderResourceType, + folderId: string +): Promise { + const activeFolders = await tx + .select({ id: folderTable.id, parentId: folderTable.parentId }) + .from(folderTable) + .where( + and( + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, resourceType), + isNull(folderTable.deletedAt) + ) + ) + + return [folderId, ...collectDescendantFolderIds(activeFolders, folderId)] +} + +/** + * Soft-deletes every folder in `folderIds` and every resource contained by them, stamping + * one shared `timestamp` across the whole cascade. + * + * The shared timestamp is load-bearing: {@link restoreFolderCascade} resurrects only rows + * whose soft-delete timestamp matches the folder's exactly, which is what stops a restore + * from also reviving siblings that were deleted independently before or after. + * + * Callers must already hold row locks on `folderIds` (see the `FOR UPDATE` in the delete + * orchestration) — this function performs no locking of its own. + */ +export async function archiveFolderCascade( + tx: DbOrTx, + config: FolderResourceConfig, + workspaceId: string, + folderIds: string[], + timestamp: Date +): Promise { + const archivedFolders = await tx + .update(folderTable) + .set({ deletedAt: timestamp, updatedAt: timestamp }) + .where( + and( + inArray(folderTable.id, folderIds), + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, config.resourceType), + isNull(folderTable.deletedAt) + ) + ) + .returning({ id: folderTable.id }) + + const archivedChildren = await tx + .update(config.table) + .set({ [config.deletedKey]: timestamp, updatedAt: timestamp }) + .where(activeChildFilter(config, workspaceId, inArray(config.folderIdColumn, folderIds) as SQL)) + .returning({ id: config.idColumn }) + + return { folders: archivedFolders.length, children: archivedChildren.length } +} + +/** + * Restores the subtree rooted at `folderId`, resurrecting only folders and resources whose + * soft-delete timestamp equals `timestamp` — i.e. exactly what was archived together. + * + * Walks children explicitly rather than reusing {@link collectFolderSubtreeIds}, which only + * sees *active* folders and by definition cannot see an archived subtree. `seen` guards + * against a parent cycle turning the walk into infinite recursion. + */ +export async function restoreFolderCascade( + tx: DbOrTx, + config: FolderResourceConfig, + workspaceId: string, + folderId: string, + timestamp: Date +): Promise { + const counts: FolderCascadeCounts = { folders: 0, children: 0 } + const seen = new Set() + + const restoreSubtree = async (currentFolderId: string): Promise => { + if (seen.has(currentFolderId)) return + seen.add(currentFolderId) + + const restoredChildren = await tx + .update(config.table) + .set({ [config.deletedKey]: null, updatedAt: new Date() }) + .where( + and( + eq(config.folderIdColumn, currentFolderId), + eq(config.workspaceColumn, workspaceId), + eq(config.deletedColumn, timestamp), + config.scope + ) as SQL + ) + .returning({ id: config.idColumn }) + counts.children += restoredChildren.length + + const archivedChildFolders = await tx + .select({ id: folderTable.id }) + .from(folderTable) + .where( + and( + eq(folderTable.parentId, currentFolderId), + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, config.resourceType), + eq(folderTable.deletedAt, timestamp) + ) + ) + + for (const child of archivedChildFolders) { + // Re-assert the timestamp on the write: the read above is unlocked, so a concurrent + // restore could have already claimed this row. An empty result means someone else + // won, and this branch is skipped rather than double-counted. + const [restored] = await tx + .update(folderTable) + .set({ deletedAt: null, updatedAt: new Date() }) + .where(and(eq(folderTable.id, child.id), eq(folderTable.deletedAt, timestamp))) + .returning({ id: folderTable.id }) + if (!restored) continue + counts.folders += 1 + await restoreSubtree(child.id) + } + } + + await restoreSubtree(folderId) + return counts +} + +/** + * Maps internal cascade counts onto the per-resourceType shape the API returns, so a + * `knowledge_base` folder reports `knowledgeBases` and a `table` folder reports `tables`. + */ +export function toCascadeCounts( + resourceType: FolderResourceType, + counts: FolderCascadeCounts +): { folders: number } & Partial> { + return { folders: counts.folders, [folderResourceConfig(resourceType).countKey]: counts.children } +} diff --git a/apps/sim/lib/folders/config.ts b/apps/sim/lib/folders/config.ts new file mode 100644 index 00000000000..0f51ac78765 --- /dev/null +++ b/apps/sim/lib/folders/config.ts @@ -0,0 +1,98 @@ +import { knowledgeBase, userTableDefinitions, workflow, workspaceFiles } from '@sim/db' +import { eq, type SQL } from 'drizzle-orm' +import type { PgColumn, PgTable } from 'drizzle-orm/pg-core' +import type { FolderResourceType } from '@/lib/api/contracts/folders' + +/** + * Counts of cascaded resources returned by a folder delete/restore, keyed per resource + * type so a caller can render "3 workflows" vs "3 tables" without inspecting the folder. + */ +export type FolderChildCountKey = 'workflows' | 'files' | 'knowledgeBases' | 'tables' + +/** + * Everything that differs between the four folder-bearing resource types, expressed as + * data. The folder engine in `lib/folders/orchestration.ts` reads this instead of + * branching on `resourceType`, so create/update/delete/restore/reorder each exist exactly + * once and adding a fifth foldered resource means adding one entry here. + * + * This is the deliberate departure from the original generic-folders prototype, which + * dispatched `resourceType` to four separate backends (workflow and file folders still + * delegated to their legacy managers). That shape forced an adapter to reshape records, + * matching on error *strings* to classify failures, and left two writers for one table. + */ +export interface FolderResourceConfig { + resourceType: FolderResourceType + /** Human-readable noun used in audit-log descriptions. */ + label: string + countKey: FolderChildCountKey + /** Table holding the resources that live *inside* folders of this type. */ + table: PgTable + idColumn: PgColumn + folderIdColumn: PgColumn + workspaceColumn: PgColumn + /** Soft-delete timestamp column; the resource is active while this is null. */ + deletedColumn: PgColumn + /** + * Property key backing {@link deletedColumn}. Drizzle's `.set()` takes TypeScript + * property names while `.where()` takes column objects, so the cascade needs both. + * These genuinely differ per table (`archivedAt` vs `deletedAt`), which is exactly the + * delta this config exists to capture. + */ + deletedKey: 'deletedAt' | 'archivedAt' + /** Narrows which rows of `table` participate in folder membership at all. */ + scope?: SQL +} + +export const FOLDER_RESOURCES: Record = { + workflow: { + resourceType: 'workflow', + label: 'workflow', + countKey: 'workflows', + table: workflow, + idColumn: workflow.id, + folderIdColumn: workflow.folderId, + workspaceColumn: workflow.workspaceId, + deletedColumn: workflow.archivedAt, + deletedKey: 'archivedAt', + }, + file: { + resourceType: 'file', + label: 'file', + countKey: 'files', + table: workspaceFiles, + idColumn: workspaceFiles.id, + folderIdColumn: workspaceFiles.folderId, + workspaceColumn: workspaceFiles.workspaceId, + deletedColumn: workspaceFiles.deletedAt, + deletedKey: 'deletedAt', + // `workspace_files` also stores copilot/chat/execution artifacts and profile + // pictures; only files surfaced on the Files page live in folders. + scope: eq(workspaceFiles.context, 'workspace'), + }, + knowledge_base: { + resourceType: 'knowledge_base', + label: 'knowledge base', + countKey: 'knowledgeBases', + table: knowledgeBase, + idColumn: knowledgeBase.id, + folderIdColumn: knowledgeBase.folderId, + workspaceColumn: knowledgeBase.workspaceId, + deletedColumn: knowledgeBase.deletedAt, + deletedKey: 'deletedAt', + }, + table: { + resourceType: 'table', + label: 'table', + countKey: 'tables', + table: userTableDefinitions, + idColumn: userTableDefinitions.id, + folderIdColumn: userTableDefinitions.folderId, + workspaceColumn: userTableDefinitions.workspaceId, + deletedColumn: userTableDefinitions.archivedAt, + deletedKey: 'archivedAt', + }, +} + +export function folderResourceConfig(resourceType: FolderResourceType): FolderResourceConfig { + return FOLDER_RESOURCES[resourceType] +} diff --git a/apps/sim/lib/folders/queries.ts b/apps/sim/lib/folders/queries.ts index d6691bd872f..43835aac503 100644 --- a/apps/sim/lib/folders/queries.ts +++ b/apps/sim/lib/folders/queries.ts @@ -1,37 +1,22 @@ import { db } from '@sim/db' -import { workflowFolder } from '@sim/db/schema' +import { folder } from '@sim/db/schema' import { and, asc, eq, isNotNull, isNull } from 'drizzle-orm' import type { FolderApi, FolderResourceType } from '@/lib/api/contracts/folders' import type { FolderQueryScope } from '@/hooks/queries/utils/folder-keys' /** - * Adapts a legacy `workflow_folder` row to the generic `FolderApi` wire shape. - * - * The wire contract has already moved to the generic vocabulary (`resourceType`, - * `deletedAt`) while the read path still targets `workflow_folder` — the writers have not - * been cut over to the `folder` table yet. Keeping the adapter here means the cutover is a - * one-file change to the query below, with no contract or consumer churn. - * - * `color`/`isExpanded` are intentionally not surfaced: neither had a consumer, and - * expansion state is client-only (see `stores/folders/types.ts`). + * Normalizes a `folder` row to the `FolderApi` wire shape (timestamps as ISO strings). * * Exported because every folder route — list AND mutations — must emit the same shape. - * `requestJson` validates responses against the contract, so a mutation returning a raw - * row fails client-side parse after the write has already succeeded. + * `requestJson` validates responses against the contract, so a mutation returning a raw row + * fails client-side parse after the write has already succeeded. */ -export function toFolderApi(row: typeof workflowFolder.$inferSelect): FolderApi { +export function toFolderApi(row: typeof folder.$inferSelect): FolderApi { return { - id: row.id, - resourceType: 'workflow', - name: row.name, - userId: row.userId, - workspaceId: row.workspaceId, - parentId: row.parentId, - locked: row.locked, - sortOrder: row.sortOrder, + ...row, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), - deletedAt: row.archivedAt ? row.archivedAt.toISOString() : null, + deletedAt: row.deletedAt ? row.deletedAt.toISOString() : null, } } @@ -41,18 +26,22 @@ export async function listFoldersForWorkspace( scope: FolderQueryScope, resourceType: FolderResourceType = 'workflow' ): Promise { - // Only workflow folders exist in the legacy table; the other resource types have no rows - // until the `folder` cutover lands, so they correctly return empty rather than erroring. + // Only workflow folders have been cut over to the `folder` table. File folders are still + // written to `workspace_file_folders` by `uploads/contexts/workspace`, so the `file` rows + // the migration backfilled here are a frozen snapshot — serving them would return stale + // data. `knowledge_base` and `table` folders have no writer at all yet. Returning empty is + // correct for those until each type's writers move over. if (resourceType !== 'workflow') return [] - const scopeFilter = - scope === 'archived' ? isNotNull(workflowFolder.archivedAt) : isNull(workflowFolder.archivedAt) + const scopeFilter = scope === 'archived' ? isNotNull(folder.deletedAt) : isNull(folder.deletedAt) const rows = await db .select() - .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, workspaceId), scopeFilter)) - .orderBy(asc(workflowFolder.sortOrder), asc(workflowFolder.createdAt)) + .from(folder) + .where( + and(eq(folder.workspaceId, workspaceId), eq(folder.resourceType, resourceType), scopeFilter) + ) + .orderBy(asc(folder.sortOrder), asc(folder.createdAt)) return rows.map(toFolderApi) } diff --git a/apps/sim/lib/logs/folder-expansion.ts b/apps/sim/lib/logs/folder-expansion.ts index 1ac5c599a70..6017c44f875 100644 --- a/apps/sim/lib/logs/folder-expansion.ts +++ b/apps/sim/lib/logs/folder-expansion.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { workflowFolder } from '@sim/db/schema' +import { folder as workflowFolder } from '@sim/db/schema' import { and, eq, isNull } from 'drizzle-orm' /** @@ -25,7 +25,13 @@ export async function expandFolderIdsWithDescendants( const rows = await db .select({ id: workflowFolder.id, parentId: workflowFolder.parentId }) .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, workspaceId), isNull(workflowFolder.archivedAt))) + .where( + and( + eq(workflowFolder.workspaceId, workspaceId), + eq(workflowFolder.resourceType, 'workflow'), + isNull(workflowFolder.deletedAt) + ) + ) const childrenByParent = new Map() for (const row of rows) { diff --git a/apps/sim/lib/workflows/lifecycle.ts b/apps/sim/lib/workflows/lifecycle.ts index d1b08caed71..7f22db060fe 100644 --- a/apps/sim/lib/workflows/lifecycle.ts +++ b/apps/sim/lib/workflows/lifecycle.ts @@ -5,7 +5,7 @@ import { webhook, workflow, workflowDeploymentVersion, - workflowFolder, + folder as workflowFolder, workflowMcpTool, workflowSchedule, workspace, @@ -240,7 +240,7 @@ export async function restoreWorkflow( let clearFolderId = false if (existingWorkflow.folderId) { const [folder] = await db - .select({ archivedAt: workflowFolder.archivedAt }) + .select({ archivedAt: workflowFolder.deletedAt }) .from(workflowFolder) .where(eq(workflowFolder.id, existingWorkflow.folderId)) diff --git a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts index c32ffbc58c3..99968c93ef6 100644 --- a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts @@ -4,7 +4,7 @@ import { chat, webhook, workflow, - workflowFolder, + folder as workflowFolder, workflowMcpTool, workflowSchedule, } from '@sim/db/schema' @@ -67,7 +67,7 @@ async function assertParentFolderInWorkspace( const [parent] = await db .select({ workspaceId: workflowFolder.workspaceId, - archivedAt: workflowFolder.archivedAt, + archivedAt: workflowFolder.deletedAt, }) .from(workflowFolder) .where(eq(workflowFolder.id, parentId)) @@ -95,7 +95,13 @@ async function nextFolderSortOrder( db .select({ minSortOrder: min(workflowFolder.sortOrder) }) .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, workspaceId), folderParentCondition)), + .where( + and( + eq(workflowFolder.workspaceId, workspaceId), + eq(workflowFolder.resourceType, 'workflow'), + folderParentCondition + ) + ), db .select({ minSortOrder: min(workflow.sortOrder) }) .from(workflow) @@ -141,11 +147,11 @@ export async function performCreateFolder( .insert(workflowFolder) .values({ id: folderId, + resourceType: 'workflow', name: params.name.trim(), userId: params.userId, workspaceId: params.workspaceId, parentId, - color: params.color || '#6B7280', sortOrder, }) .returning() @@ -164,7 +170,6 @@ export async function performCreateFolder( name: folder.name, workspaceId: params.workspaceId, parentId: parentId || undefined, - color: folder.color, sortOrder: folder.sortOrder, }, }) @@ -212,7 +217,8 @@ export async function performUpdateFolder( .where( and( eq(workflowFolder.id, params.folderId), - eq(workflowFolder.workspaceId, params.workspaceId) + eq(workflowFolder.workspaceId, params.workspaceId), + eq(workflowFolder.resourceType, 'workflow') ) ) .returning() @@ -250,7 +256,8 @@ async function deleteFolderRecursively( and( eq(workflowFolder.parentId, folderId), eq(workflowFolder.workspaceId, workspaceId), - isNull(workflowFolder.archivedAt) + eq(workflowFolder.resourceType, 'workflow'), + isNull(workflowFolder.deletedAt) ) ) @@ -282,7 +289,7 @@ async function deleteFolderRecursively( await db .update(workflowFolder) - .set({ archivedAt: timestamp }) + .set({ deletedAt: timestamp }) .where(eq(workflowFolder.id, folderId)) stats.folders += 1 @@ -318,7 +325,8 @@ async function countWorkflowsInFolderRecursively( and( eq(workflowFolder.parentId, folderId), eq(workflowFolder.workspaceId, workspaceId), - isNull(workflowFolder.archivedAt) + eq(workflowFolder.resourceType, 'workflow'), + isNull(workflowFolder.deletedAt) ) ) @@ -406,7 +414,7 @@ async function restoreFolderRecursively( ): Promise<{ folders: number; workflows: number }> { const stats = { folders: 0, workflows: 0 } - await tx.update(workflowFolder).set({ archivedAt: null }).where(eq(workflowFolder.id, folderId)) + await tx.update(workflowFolder).set({ deletedAt: null }).where(eq(workflowFolder.id, folderId)) stats.folders += 1 const archivedWorkflows = await tx @@ -447,7 +455,8 @@ async function restoreFolderRecursively( and( eq(workflowFolder.parentId, folderId), eq(workflowFolder.workspaceId, workspaceId), - eq(workflowFolder.archivedAt, folderArchivedAt) + eq(workflowFolder.resourceType, 'workflow'), + eq(workflowFolder.deletedAt, folderArchivedAt) ) ) @@ -487,13 +496,19 @@ export async function performRestoreFolder( const [folder] = await db .select() .from(workflowFolder) - .where(and(eq(workflowFolder.id, folderId), eq(workflowFolder.workspaceId, workspaceId))) + .where( + and( + eq(workflowFolder.id, folderId), + eq(workflowFolder.workspaceId, workspaceId), + eq(workflowFolder.resourceType, 'workflow') + ) + ) if (!folder) { return { success: false, error: 'Folder not found' } } - if (!folder.archivedAt) { + if (!folder.deletedAt) { return { success: true, restoredItems: { folders: 0, workflows: 0 } } } @@ -506,7 +521,7 @@ export async function performRestoreFolder( const restoredStats = await db.transaction(async (tx) => { if (folder.parentId) { const [parentFolder] = await tx - .select({ archivedAt: workflowFolder.archivedAt }) + .select({ archivedAt: workflowFolder.deletedAt }) .from(workflowFolder) .where(eq(workflowFolder.id, folder.parentId)) @@ -518,7 +533,7 @@ export async function performRestoreFolder( } } - return restoreFolderRecursively(folderId, workspaceId, folder.archivedAt!, tx) + return restoreFolderRecursively(folderId, workspaceId, folder.deletedAt!, tx) }) logger.info('Restored folder and all contents:', { folderId, restoredStats }) diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts index 4b07cd492fe..128117cbaec 100644 --- a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts @@ -1,6 +1,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { workflow, workflowFolder } from '@sim/db/schema' +import { workflow, folder as workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { isFolderInWorkspace } from '@sim/platform-authz/workflow' import { toError } from '@sim/utils/errors' @@ -138,7 +138,13 @@ async function nextWorkflowSortOrder( db .select({ minOrder: min(workflowFolder.sortOrder) }) .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, workspaceId), folderParentCondition)), + .where( + and( + eq(workflowFolder.workspaceId, workspaceId), + eq(workflowFolder.resourceType, 'workflow'), + folderParentCondition + ) + ), ]) const minSortOrder = [workflowMinResult?.minOrder, folderMinResult?.minOrder].reduce< diff --git a/apps/sim/lib/workflows/persistence/duplicate.test.ts b/apps/sim/lib/workflows/persistence/duplicate.test.ts index b8328c900e2..c2a09c242ae 100644 --- a/apps/sim/lib/workflows/persistence/duplicate.test.ts +++ b/apps/sim/lib/workflows/persistence/duplicate.test.ts @@ -37,7 +37,7 @@ function queueDuplicateFixtures(options: { }) { queueTableRows(schemaMock.workflow, [options.sourceWorkflow]) queueTableRows(schemaMock.workflow, options.workflowMin ?? []) - queueTableRows(schemaMock.workflowFolder, options.folderMin ?? []) + queueTableRows(schemaMock.folder, options.folderMin ?? []) queueTableRows(schemaMock.workflowBlocks, options.blocks ?? []) queueTableRows(schemaMock.workflowEdges, options.edges ?? []) queueTableRows(schemaMock.workflowSubflows, options.subflows ?? []) diff --git a/apps/sim/lib/workflows/persistence/duplicate.ts b/apps/sim/lib/workflows/persistence/duplicate.ts index 9895a90fb7f..82942ad19d9 100644 --- a/apps/sim/lib/workflows/persistence/duplicate.ts +++ b/apps/sim/lib/workflows/persistence/duplicate.ts @@ -3,7 +3,7 @@ import { workflow, workflowBlocks, workflowEdges, - workflowFolder, + folder as workflowFolder, workflowSubflows, } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -77,7 +77,7 @@ async function assertTargetFolderMutable( parentId: workflowFolder.parentId, workspaceId: workflowFolder.workspaceId, locked: workflowFolder.locked, - archivedAt: workflowFolder.archivedAt, + archivedAt: workflowFolder.deletedAt, }) .from(workflowFolder) .where(eq(workflowFolder.id, currentFolderId)) @@ -193,7 +193,13 @@ export async function duplicateWorkflow( tx .select({ minOrder: min(workflowFolder.sortOrder) }) .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, targetWorkspaceId), folderParentCondition)), + .where( + and( + eq(workflowFolder.workspaceId, targetWorkspaceId), + eq(workflowFolder.resourceType, 'workflow'), + folderParentCondition + ) + ), ]) const minSortOrder = [workflowMinResult?.minOrder, folderMinResult?.minOrder].reduce< number | null diff --git a/apps/sim/lib/workflows/utils.ts b/apps/sim/lib/workflows/utils.ts index c6406174314..1b492f3a282 100644 --- a/apps/sim/lib/workflows/utils.ts +++ b/apps/sim/lib/workflows/utils.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { workflowFolder, workflow as workflowTable } from '@sim/db/schema' +import { folder as workflowFolder, workflow as workflowTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { generateId } from '@sim/utils/id' @@ -418,7 +418,13 @@ export async function createWorkflowRecord(params: CreateWorkflowInput) { db .select({ minOrder: min(workflowFolder.sortOrder) }) .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, workspaceId), folderParentCondition)), + .where( + and( + eq(workflowFolder.workspaceId, workspaceId), + eq(workflowFolder.resourceType, 'workflow'), + folderParentCondition + ) + ), ]) const minSortOrder = [workflowMinResult?.minOrder, folderMinResult?.minOrder].reduce< @@ -500,7 +506,13 @@ export async function verifyFolderWorkspace( const [row] = await db .select({ id: workflowFolder.id }) .from(workflowFolder) - .where(and(eq(workflowFolder.id, folderId), eq(workflowFolder.workspaceId, workspaceId))) + .where( + and( + eq(workflowFolder.id, folderId), + eq(workflowFolder.workspaceId, workspaceId), + eq(workflowFolder.resourceType, 'workflow') + ) + ) .limit(1) return Boolean(row) } @@ -541,6 +553,12 @@ export async function listFolders(workspaceId: string) { locked: workflowFolder.locked, }) .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, workspaceId), isNull(workflowFolder.archivedAt))) + .where( + and( + eq(workflowFolder.workspaceId, workspaceId), + eq(workflowFolder.resourceType, 'workflow'), + isNull(workflowFolder.deletedAt) + ) + ) .orderBy(asc(workflowFolder.sortOrder), asc(workflowFolder.createdAt)) } diff --git a/packages/platform-authz/src/workflow.ts b/packages/platform-authz/src/workflow.ts index c1d643a5850..e238a45cf34 100644 --- a/packages/platform-authz/src/workflow.ts +++ b/packages/platform-authz/src/workflow.ts @@ -1,4 +1,4 @@ -import { db, workflow, workflowFolder, workspace } from '@sim/db' +import { db, workflow, folder as workflowFolder, workspace } from '@sim/db' import { and, eq, isNull } from 'drizzle-orm' import { type PermissionType, @@ -112,7 +112,7 @@ export async function getFolderLockStatus(folderId: string | null): Promise Date: Tue, 28 Jul 2026 16:20:31 -0700 Subject: [PATCH 3/7] fix(folders): scope every folder query to its resource type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review + cleanup pass over the cutover. - id-keyed lookups were left unfiltered on the theory that UUIDs cannot collide across resource types. That rules out accidental collision but not a caller passing a file/kb/table folder id as a workflow folderId — isFolderInWorkspace and the folder PUT/DELETE/parent checks would have accepted one. Every folder query now carries the resourceType filter - the soft-delete cleanup job pointed at the shared folder table with no resource scope, so it would have hard-deleted file/kb/table rows the workflow cleanup does not own; batchDeleteByWorkspaceAndTimestamp gains an additionalPredicate and the folder target uses it - drop lib/folders/cascade.ts and config.ts: unreferenced, and a second recursive cascade engine over the same table that duplicates folder-lifecycle's. They belong with their first consumer - narrow listFoldersQuerySchema.resourceType to the servable set instead of accepting the full enum and silently answering "you have none", matching the create and reorder bodies - de-alias `folder as workflowFolder` to `folderTable`; collapse six doubled resourceType predicates; drop the orphaned CreateFolderInput and restore TSDoc lost to the dead-code delete --- .../app/api/folders/[id]/duplicate/route.ts | 70 ++++---- apps/sim/app/api/folders/[id]/route.ts | 12 +- apps/sim/app/api/folders/reorder/route.ts | 35 ++-- .../api/v1/admin/folders/[id]/export/route.ts | 25 ++- apps/sim/app/api/v1/admin/types.ts | 4 +- .../v1/admin/workspaces/[id]/export/route.ts | 10 +- .../v1/admin/workspaces/[id]/folders/route.ts | 20 +-- .../v1/admin/workspaces/[id]/import/route.ts | 6 +- .../app/api/v1/admin/workspaces/[id]/route.ts | 10 +- apps/sim/background/cleanup-soft-deletes.ts | 15 +- .../lib/copy/copy-workflows.ts | 20 +-- apps/sim/lib/api/contracts/folders.ts | 8 +- apps/sim/lib/cleanup/batch-delete.ts | 10 +- .../sim/lib/copilot/chat/workspace-context.ts | 16 +- .../copilot/vfs/workflow-alias-resolver.ts | 18 +- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 16 +- apps/sim/lib/folders/cascade.ts | 164 ------------------ apps/sim/lib/folders/config.ts | 98 ----------- apps/sim/lib/folders/queries.ts | 7 - apps/sim/lib/logs/folder-expansion.ts | 12 +- apps/sim/lib/workflows/lifecycle.ts | 10 +- .../orchestration/folder-lifecycle.ts | 99 ++++++----- .../orchestration/workflow-lifecycle.ts | 14 +- .../lib/workflows/persistence/duplicate.ts | 28 +-- apps/sim/lib/workflows/utils.ts | 61 ++++--- packages/platform-authz/src/workflow.ts | 26 +-- 26 files changed, 281 insertions(+), 533 deletions(-) delete mode 100644 apps/sim/lib/folders/cascade.ts delete mode 100644 apps/sim/lib/folders/config.ts diff --git a/apps/sim/app/api/folders/[id]/duplicate/route.ts b/apps/sim/app/api/folders/[id]/duplicate/route.ts index 1f987794a79..70f247c7009 100644 --- a/apps/sim/app/api/folders/[id]/duplicate/route.ts +++ b/apps/sim/app/api/folders/[id]/duplicate/route.ts @@ -1,6 +1,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { workflow, folder as workflowFolder } from '@sim/db/schema' +import { folder as folderTable, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { FolderLockedError } from '@sim/platform-authz/workflow' import { generateId } from '@sim/utils/id' @@ -40,8 +40,14 @@ export const POST = withRouteHandler( const sourceFolder = await db .select() - .from(workflowFolder) - .where(and(eq(workflowFolder.id, sourceFolderId), isNull(workflowFolder.deletedAt))) + .from(folderTable) + .where( + and( + eq(folderTable.id, sourceFolderId), + isNull(folderTable.deletedAt), + eq(folderTable.resourceType, 'workflow') + ) + ) .then((rows) => rows[0]) if (!sourceFolder) { @@ -70,20 +76,20 @@ export const POST = withRouteHandler( await assertTargetParentFolderMutable(tx, targetParentId, targetWorkspaceId, sourceFolderId) const folderParentCondition = targetParentId - ? eq(workflowFolder.parentId, targetParentId) - : isNull(workflowFolder.parentId) + ? eq(folderTable.parentId, targetParentId) + : isNull(folderTable.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) + .select({ minSortOrder: min(folderTable.sortOrder) }) + .from(folderTable) .where( and( - eq(workflowFolder.workspaceId, targetWorkspaceId), - eq(workflowFolder.resourceType, 'workflow'), + eq(folderTable.workspaceId, targetWorkspaceId), + eq(folderTable.resourceType, 'workflow'), folderParentCondition ) ), @@ -108,7 +114,7 @@ export const POST = withRouteHandler( name ) - await tx.insert(workflowFolder).values({ + await tx.insert(folderTable).values({ id: newFolderId, resourceType: 'workflow', userId: session.user.id, @@ -174,8 +180,8 @@ export const POST = withRouteHandler( const duplicatedFolder = await db .select() - .from(workflowFolder) - .where(eq(workflowFolder.id, newFolderId)) + .from(folderTable) + .where(and(eq(folderTable.id, newFolderId), eq(folderTable.resourceType, 'workflow'))) .then((rows) => rows[0]) return NextResponse.json({ folder: toFolderApi(duplicatedFolder) }, { status: 201 }) @@ -235,14 +241,14 @@ async function assertTargetParentFolderMutable( visited.add(currentFolderId) const [folder] = await tx .select({ - id: workflowFolder.id, - parentId: workflowFolder.parentId, - workspaceId: workflowFolder.workspaceId, - locked: workflowFolder.locked, - archivedAt: workflowFolder.deletedAt, + id: folderTable.id, + parentId: folderTable.parentId, + workspaceId: folderTable.workspaceId, + locked: folderTable.locked, + archivedAt: folderTable.deletedAt, }) - .from(workflowFolder) - .where(eq(workflowFolder.id, currentFolderId)) + .from(folderTable) + .where(and(eq(folderTable.id, currentFolderId), eq(folderTable.resourceType, 'workflow'))) .limit(1) if (!folder || folder.workspaceId !== targetWorkspaceId || folder.archivedAt) { @@ -266,17 +272,17 @@ async function deduplicateFolderName( requestedName: string ): Promise { const parentCondition = parentId - ? eq(workflowFolder.parentId, parentId) - : isNull(workflowFolder.parentId) + ? eq(folderTable.parentId, parentId) + : isNull(folderTable.parentId) const siblingRows = await tx - .select({ name: workflowFolder.name }) - .from(workflowFolder) + .select({ name: folderTable.name }) + .from(folderTable) .where( and( - eq(workflowFolder.workspaceId, workspaceId), - eq(workflowFolder.resourceType, 'workflow'), + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, 'workflow'), parentCondition, - isNull(workflowFolder.deletedAt) + isNull(folderTable.deletedAt) ) ) const siblingNames = new Set(siblingRows.map((row) => row.name)) @@ -303,13 +309,13 @@ async function duplicateFolderStructure( ): Promise { const childFolders = await tx .select() - .from(workflowFolder) + .from(folderTable) .where( and( - eq(workflowFolder.parentId, sourceFolderId), - eq(workflowFolder.workspaceId, sourceWorkspaceId), - eq(workflowFolder.resourceType, 'workflow'), - isNull(workflowFolder.deletedAt) + eq(folderTable.parentId, sourceFolderId), + eq(folderTable.workspaceId, sourceWorkspaceId), + eq(folderTable.resourceType, 'workflow'), + isNull(folderTable.deletedAt) ) ) @@ -317,7 +323,7 @@ async function duplicateFolderStructure( const newChildFolderId = generateId() folderMapping.set(childFolder.id, newChildFolderId) - await tx.insert(workflowFolder).values({ + await tx.insert(folderTable).values({ id: newChildFolderId, resourceType: 'workflow', userId, diff --git a/apps/sim/app/api/folders/[id]/route.ts b/apps/sim/app/api/folders/[id]/route.ts index 0ff58b6a007..960ede19939 100644 --- a/apps/sim/app/api/folders/[id]/route.ts +++ b/apps/sim/app/api/folders/[id]/route.ts @@ -1,8 +1,8 @@ import { db } from '@sim/db' -import { folder as workflowFolder } from '@sim/db/schema' +import { folder as folderTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' -import { eq } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { updateFolderContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' @@ -44,8 +44,8 @@ export const PUT = withRouteHandler( // Verify the folder exists const existingFolder = await db .select() - .from(workflowFolder) - .where(eq(workflowFolder.id, id)) + .from(folderTable) + .where(and(eq(folderTable.id, id), eq(folderTable.resourceType, 'workflow'))) .then((rows) => rows[0]) if (!existingFolder) { @@ -125,8 +125,8 @@ export const DELETE = withRouteHandler( // Verify the folder exists const existingFolder = await db .select() - .from(workflowFolder) - .where(eq(workflowFolder.id, id)) + .from(folderTable) + .where(and(eq(folderTable.id, id), eq(folderTable.resourceType, 'workflow'))) .then((rows) => rows[0]) if (!existingFolder) { diff --git a/apps/sim/app/api/folders/reorder/route.ts b/apps/sim/app/api/folders/reorder/route.ts index 9c80e3f49d5..d734cea4047 100644 --- a/apps/sim/app/api/folders/reorder/route.ts +++ b/apps/sim/app/api/folders/reorder/route.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { folder as workflowFolder } from '@sim/db/schema' +import { folder as folderTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' import { and, eq, inArray } from 'drizzle-orm' @@ -37,9 +37,9 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { const folderIds = updates.map((u) => u.id) const existingFolders = await db - .select({ id: workflowFolder.id, workspaceId: workflowFolder.workspaceId }) - .from(workflowFolder) - .where(inArray(workflowFolder.id, folderIds)) + .select({ id: folderTable.id, workspaceId: folderTable.workspaceId }) + .from(folderTable) + .where(and(inArray(folderTable.id, folderIds), eq(folderTable.resourceType, 'workflow'))) const validIds = new Set( existingFolders.filter((f) => f.workspaceId === workspaceId).map((f) => f.id) @@ -58,12 +58,14 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { if (targetParentIds.length > 0) { const parentFolders = await db .select({ - id: workflowFolder.id, - workspaceId: workflowFolder.workspaceId, - archivedAt: workflowFolder.deletedAt, + id: folderTable.id, + workspaceId: folderTable.workspaceId, + archivedAt: folderTable.deletedAt, }) - .from(workflowFolder) - .where(inArray(workflowFolder.id, targetParentIds)) + .from(folderTable) + .where( + and(inArray(folderTable.id, targetParentIds), eq(folderTable.resourceType, 'workflow')) + ) const validParentIds = new Set( parentFolders.filter((f) => f.workspaceId === workspaceId && !f.archivedAt).map((f) => f.id) @@ -81,14 +83,10 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { } const workspaceFolders = await db - .select({ id: workflowFolder.id, parentId: workflowFolder.parentId }) - .from(workflowFolder) + .select({ id: folderTable.id, parentId: folderTable.parentId }) + .from(folderTable) .where( - and( - eq(workflowFolder.workspaceId, workspaceId), - eq(workflowFolder.resourceType, 'workflow'), - eq(workflowFolder.resourceType, 'workflow') - ) + and(eq(folderTable.workspaceId, workspaceId), eq(folderTable.resourceType, 'workflow')) ) const parentById = new Map() @@ -132,7 +130,10 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { if (update.parentId !== undefined) { updateData.parentId = update.parentId || null } - await tx.update(workflowFolder).set(updateData).where(eq(workflowFolder.id, update.id)) + await tx + .update(folderTable) + .set(updateData) + .where(and(eq(folderTable.id, update.id), eq(folderTable.resourceType, 'workflow'))) } }) diff --git a/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts b/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts index 55c3f8982d4..498a77d0a38 100644 --- a/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts +++ b/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts @@ -12,7 +12,7 @@ */ import { db } from '@sim/db' -import { workflow, folder as workflowFolder } from '@sim/db/schema' +import { folder as folderTable, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { NextResponse } from 'next/server' @@ -106,12 +106,12 @@ export const GET = withRouteHandler( try { const [folderData] = await db .select({ - id: workflowFolder.id, - name: workflowFolder.name, - workspaceId: workflowFolder.workspaceId, + id: folderTable.id, + name: folderTable.name, + workspaceId: folderTable.workspaceId, }) - .from(workflowFolder) - .where(eq(workflowFolder.id, folderId)) + .from(folderTable) + .where(and(eq(folderTable.id, folderId), eq(folderTable.resourceType, 'workflow'))) .limit(1) if (!folderData) { @@ -125,16 +125,15 @@ export const GET = withRouteHandler( const allFolders = await db .select({ - id: workflowFolder.id, - name: workflowFolder.name, - parentId: workflowFolder.parentId, + id: folderTable.id, + name: folderTable.name, + parentId: folderTable.parentId, }) - .from(workflowFolder) + .from(folderTable) .where( and( - eq(workflowFolder.workspaceId, folderData.workspaceId), - eq(workflowFolder.resourceType, 'workflow'), - eq(workflowFolder.resourceType, 'workflow') + eq(folderTable.workspaceId, folderData.workspaceId), + eq(folderTable.resourceType, 'workflow') ) ) diff --git a/apps/sim/app/api/v1/admin/types.ts b/apps/sim/app/api/v1/admin/types.ts index 09c10605948..4256076d457 100644 --- a/apps/sim/app/api/v1/admin/types.ts +++ b/apps/sim/app/api/v1/admin/types.ts @@ -7,13 +7,13 @@ import type { auditLog, + folder as folderTable, member, organization, subscription, user, userStats, workflow, - folder as workflowFolder, workspace, } from '@sim/db/schema' import type { InferSelectModel } from 'drizzle-orm' @@ -27,7 +27,7 @@ import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/typ export type DbUser = InferSelectModel export type DbWorkspace = InferSelectModel export type DbWorkflow = InferSelectModel -export type DbWorkflowFolder = InferSelectModel +export type DbWorkflowFolder = InferSelectModel export type DbOrganization = InferSelectModel export type DbSubscription = InferSelectModel export type DbMember = InferSelectModel diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts index f9a354ca6e3..c3c80b9d790 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts @@ -13,7 +13,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { workflow, folder as workflowFolder, workspace } from '@sim/db/schema' +import { folder as folderTable, workflow, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { NextResponse } from 'next/server' @@ -68,13 +68,9 @@ export const GET = withRouteHandler( const folders = await db .select() - .from(workflowFolder) + .from(folderTable) .where( - and( - eq(workflowFolder.workspaceId, workspaceId), - eq(workflowFolder.resourceType, 'workflow'), - eq(workflowFolder.resourceType, 'workflow') - ) + and(eq(folderTable.workspaceId, workspaceId), eq(folderTable.resourceType, 'workflow')) ) const workflowExports: Array<{ diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/folders/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/folders/route.ts index e42b12f89a5..f2786433ce3 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/folders/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/folders/route.ts @@ -11,7 +11,7 @@ */ import { db } from '@sim/db' -import { folder as workflowFolder, workspace } from '@sim/db/schema' +import { folder as folderTable, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, count, eq } from 'drizzle-orm' import { adminV1ListWorkspaceFoldersContract } from '@/lib/api/contracts/v1/admin' @@ -49,25 +49,17 @@ export const GET = withRouteHandler( const [countResult, folders] = await Promise.all([ db .select({ total: count() }) - .from(workflowFolder) + .from(folderTable) .where( - and( - eq(workflowFolder.workspaceId, workspaceId), - eq(workflowFolder.resourceType, 'workflow'), - eq(workflowFolder.resourceType, 'workflow') - ) + and(eq(folderTable.workspaceId, workspaceId), eq(folderTable.resourceType, 'workflow')) ), db .select() - .from(workflowFolder) + .from(folderTable) .where( - and( - eq(workflowFolder.workspaceId, workspaceId), - eq(workflowFolder.resourceType, 'workflow'), - eq(workflowFolder.resourceType, 'workflow') - ) + and(eq(folderTable.workspaceId, workspaceId), eq(folderTable.resourceType, 'workflow')) ) - .orderBy(workflowFolder.sortOrder, workflowFolder.name) + .orderBy(folderTable.sortOrder, folderTable.name) .limit(limit) .offset(offset), ]) diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts index 876629e9bb4..25e46fe94ad 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts @@ -24,7 +24,7 @@ */ import { db } from '@sim/db' -import { workflow, folder as workflowFolder } from '@sim/db/schema' +import { folder as folderTable, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' @@ -152,7 +152,7 @@ export const POST = withRouteHandler( let rootFolderId: string | undefined if (rootFolderName && createFolders) { rootFolderId = generateId() - await db.insert(workflowFolder).values({ + await db.insert(folderTable).values({ id: rootFolderId, resourceType: 'workflow', name: rootFolderName, @@ -231,7 +231,7 @@ async function importSingleWorkflow( if (!folderMap.has(fullPath)) { const folderId = generateId() - await db.insert(workflowFolder).values({ + await db.insert(folderTable).values({ id: folderId, resourceType: 'workflow', name: wf.folderPath[i], diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/route.ts index fc0c32660b1..8e822ca5459 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/route.ts @@ -7,7 +7,7 @@ */ import { db } from '@sim/db' -import { workflow, folder as workflowFolder, workspace } from '@sim/db/schema' +import { folder as folderTable, workflow, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, count, eq } from 'drizzle-orm' import { adminV1GetWorkspaceContract } from '@/lib/api/contracts/v1/admin' @@ -49,13 +49,9 @@ export const GET = withRouteHandler( db.select({ count: count() }).from(workflow).where(eq(workflow.workspaceId, workspaceId)), db .select({ count: count() }) - .from(workflowFolder) + .from(folderTable) .where( - and( - eq(workflowFolder.workspaceId, workspaceId), - eq(workflowFolder.resourceType, 'workflow'), - eq(workflowFolder.resourceType, 'workflow') - ) + and(eq(folderTable.workspaceId, workspaceId), eq(folderTable.resourceType, 'workflow')) ), ]) diff --git a/apps/sim/background/cleanup-soft-deletes.ts b/apps/sim/background/cleanup-soft-deletes.ts index 60d4c0c8d52..4ec2b4e02dc 100644 --- a/apps/sim/background/cleanup-soft-deletes.ts +++ b/apps/sim/background/cleanup-soft-deletes.ts @@ -2,12 +2,12 @@ import { db, dbFor } from '@sim/db' import { copilotChats, document, + folder as folderTable, knowledgeBase, mcpServers, memory, userTableDefinitions, workflow, - folder as workflowFolder, workflowMcpServer, workspaceFile, workspaceFiles, @@ -413,10 +413,14 @@ async function cleanupExpiredKnowledgeBases( */ const CLEANUP_TARGETS = [ { - table: workflowFolder, - softDeleteCol: workflowFolder.deletedAt, - wsCol: workflowFolder.workspaceId, - name: 'workflowFolder', + table: folderTable, + softDeleteCol: folderTable.deletedAt, + wsCol: folderTable.workspaceId, + // `folder` is shared by all four resource types. Only workflow folders are cut over to + // it; file/knowledge_base/table rows are still owned elsewhere, so this pass must not + // hard-delete them. + additionalPredicate: eq(folderTable.resourceType, 'workflow'), + name: 'folder', }, { table: userTableDefinitions, @@ -675,6 +679,7 @@ export async function runCleanupSoftDeletes(payload: CleanupJobPayload): Promise retentionDate, tableName: `${label}/${target.name}`, requireTimestampNotNull: true, + additionalPredicate: 'additionalPredicate' in target ? target.additionalPredicate : undefined, dbClient: cleanupDb, }) totalDeleted += result.deleted diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts index a6ead16095c..514954d74d0 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts @@ -1,4 +1,4 @@ -import { workflow, workflowBlocks, folder as workflowFolder } from '@sim/db/schema' +import { folder as folderTable, workflow, workflowBlocks } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, inArray, isNull } from 'drizzle-orm' @@ -73,12 +73,12 @@ export async function resolveForkFolderMapping({ const sourceFolders = await tx .select() - .from(workflowFolder) + .from(folderTable) .where( and( - eq(workflowFolder.workspaceId, sourceWorkspaceId), - eq(workflowFolder.resourceType, 'workflow'), - isNull(workflowFolder.deletedAt) + eq(folderTable.workspaceId, sourceWorkspaceId), + eq(folderTable.resourceType, 'workflow'), + isNull(folderTable.deletedAt) ) ) @@ -100,12 +100,12 @@ export async function resolveForkFolderMapping({ const targetFolders = await tx .select() - .from(workflowFolder) + .from(folderTable) .where( and( - eq(workflowFolder.workspaceId, targetWorkspaceId), - eq(workflowFolder.resourceType, 'workflow'), - isNull(workflowFolder.deletedAt) + eq(folderTable.workspaceId, targetWorkspaceId), + eq(folderTable.resourceType, 'workflow'), + isNull(folderTable.deletedAt) ) ) @@ -157,7 +157,7 @@ export async function resolveForkFolderMapping({ } if (newFolders.length > 0) { - await tx.insert(workflowFolder).values(newFolders) + await tx.insert(folderTable).values(newFolders) } return map diff --git a/apps/sim/lib/api/contracts/folders.ts b/apps/sim/lib/api/contracts/folders.ts index f82847070bf..a75b4bd3a7e 100644 --- a/apps/sim/lib/api/contracts/folders.ts +++ b/apps/sim/lib/api/contracts/folders.ts @@ -29,7 +29,13 @@ export type FolderApi = z.output export const listFoldersQuerySchema = z.object({ workspaceId: z.string({ error: 'Workspace ID is required' }).min(1, 'Workspace ID is required'), - resourceType: folderResourceTypeSchema.default('workflow'), + /** + * Only workflow folders are served today — file folders are still written to + * `workspace_file_folders` and kb/table have no writer yet. Narrowed rather than + * accepting the full enum and silently answering "you have none", matching the same + * choice made on the create and reorder bodies. Widens as each type's writers land. + */ + resourceType: z.literal('workflow').default('workflow'), scope: folderScopeSchema.default('active'), }) diff --git a/apps/sim/lib/cleanup/batch-delete.ts b/apps/sim/lib/cleanup/batch-delete.ts index 7b0ede50fd9..6e51c6ceb77 100644 --- a/apps/sim/lib/cleanup/batch-delete.ts +++ b/apps/sim/lib/cleanup/batch-delete.ts @@ -1,6 +1,6 @@ import { db } from '@sim/db' import { createLogger } from '@sim/logger' -import { and, inArray, isNotNull, lt, sql } from 'drizzle-orm' +import { and, inArray, isNotNull, lt, type SQL, sql } from 'drizzle-orm' import type { PgColumn, PgTable } from 'drizzle-orm/pg-core' const logger = createLogger('BatchDelete') @@ -196,6 +196,12 @@ export interface BatchDeleteOptions { tableName: string /** When true, also requires `timestampCol IS NOT NULL` (soft-delete semantics). */ requireTimestampNotNull?: boolean + /** + * Extra predicate ANDed into the row selection. Needed for tables shared by several + * resource kinds (e.g. `folder`, which holds workflow/file/knowledge_base/table rows) + * so a cleanup pass only ever removes the kind it owns. + */ + additionalPredicate?: SQL batchSize?: number maxBatches?: number workspaceChunkSize?: number @@ -216,6 +222,7 @@ export async function batchDeleteByWorkspaceAndTimestamp({ retentionDate, tableName, requireTimestampNotNull = false, + additionalPredicate, dbClient = db, ...rest }: BatchDeleteOptions): Promise { @@ -227,6 +234,7 @@ export async function batchDeleteByWorkspaceAndTimestamp({ selectChunk: (chunkIds, limit) => { const predicates = [inArray(workspaceIdCol, chunkIds), lt(timestampCol, retentionDate)] if (requireTimestampNotNull) predicates.push(isNotNull(timestampCol)) + if (additionalPredicate) predicates.push(additionalPredicate) return dbClient .select({ id: sql`id` }) .from(tableDef) diff --git a/apps/sim/lib/copilot/chat/workspace-context.ts b/apps/sim/lib/copilot/chat/workspace-context.ts index 82e40a43cbe..f06cc1ec17a 100644 --- a/apps/sim/lib/copilot/chat/workspace-context.ts +++ b/apps/sim/lib/copilot/chat/workspace-context.ts @@ -1,11 +1,11 @@ import { db } from '@sim/db' import { + folder as folderTable, knowledgeBase, knowledgeConnector, mcpServers, userTableDefinitions, workflow, - folder as workflowFolder, workflowSchedule, } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -379,16 +379,16 @@ async function buildWorkspaceMdData( db .select({ - id: workflowFolder.id, - name: workflowFolder.name, - parentId: workflowFolder.parentId, + id: folderTable.id, + name: folderTable.name, + parentId: folderTable.parentId, }) - .from(workflowFolder) + .from(folderTable) .where( and( - eq(workflowFolder.workspaceId, workspaceId), - eq(workflowFolder.resourceType, 'workflow'), - isNull(workflowFolder.deletedAt) + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, 'workflow'), + isNull(folderTable.deletedAt) ) ), diff --git a/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts b/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts index 49a38db9583..7fa6fa52f7d 100644 --- a/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts +++ b/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { workflow, folder as workflowFolder } from '@sim/db/schema' +import { folder as folderTable, workflow } from '@sim/db/schema' import { and, asc, eq, isNull } from 'drizzle-orm' import { buildWorkflowAliasWorkflowEntries, @@ -40,19 +40,19 @@ export async function resolveWorkflowAliasForWorkspace(args: { .orderBy(asc(workflow.sortOrder), asc(workflow.createdAt)), db .select({ - folderId: workflowFolder.id, - folderName: workflowFolder.name, - parentId: workflowFolder.parentId, + folderId: folderTable.id, + folderName: folderTable.name, + parentId: folderTable.parentId, }) - .from(workflowFolder) + .from(folderTable) .where( and( - eq(workflowFolder.workspaceId, args.workspaceId), - eq(workflowFolder.resourceType, 'workflow'), - isNull(workflowFolder.deletedAt) + eq(folderTable.workspaceId, args.workspaceId), + eq(folderTable.resourceType, 'workflow'), + isNull(folderTable.deletedAt) ) ) - .orderBy(asc(workflowFolder.sortOrder), asc(workflowFolder.createdAt)), + .orderBy(asc(folderTable.sortOrder), asc(folderTable.createdAt)), ]) return resolveWorkflowAliasPath( canonicalPath, diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 2f406491a2a..24d788280b6 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -5,6 +5,7 @@ import { copilotChats, customTools as customToolsTable, document, + folder as folderTable, jobExecutionLogs, knowledgeBaseTagDefinitions, knowledgeConnector, @@ -12,7 +13,6 @@ import { skill as skillTable, workflowDeploymentVersion, workflowExecutionLogs, - folder as workflowFolder, workflowMcpServer, workflowMcpTool, workflowSchedule, @@ -2411,16 +2411,16 @@ export class WorkspaceVFS { listWorkflows(workspaceId, { scope: 'archived' }), db .select({ - id: workflowFolder.id, - name: workflowFolder.name, - archivedAt: workflowFolder.deletedAt, + id: folderTable.id, + name: folderTable.name, + archivedAt: folderTable.deletedAt, }) - .from(workflowFolder) + .from(folderTable) .where( and( - eq(workflowFolder.workspaceId, workspaceId), - eq(workflowFolder.resourceType, 'workflow'), - isNotNull(workflowFolder.deletedAt) + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, 'workflow'), + isNotNull(folderTable.deletedAt) ) ), listTables(workspaceId, { scope: 'archived' }), diff --git a/apps/sim/lib/folders/cascade.ts b/apps/sim/lib/folders/cascade.ts deleted file mode 100644 index 7bb7937320e..00000000000 --- a/apps/sim/lib/folders/cascade.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { type db, folder as folderTable } from '@sim/db' -import { and, eq, inArray, isNull, type SQL } from 'drizzle-orm' -import type { FolderResourceType } from '@/lib/api/contracts/folders' -import { type FolderResourceConfig, folderResourceConfig } from '@/lib/folders/config' -import { collectDescendantFolderIds } from '@/lib/folders/subtree' - -/** Narrow enough for both `db` and an open transaction handle. */ -type DbOrTx = Pick - -export interface FolderCascadeCounts { - folders: number - children: number -} - -function activeChildFilter(config: FolderResourceConfig, workspaceId: string, ids: SQL): SQL { - return and( - ids, - eq(config.workspaceColumn, workspaceId), - isNull(config.deletedColumn), - config.scope - ) as SQL -} - -/** - * Collects a folder's full subtree (itself plus every active descendant) within one - * resourceType. The walk itself is pure and shared with the other subtree consumers. - */ -export async function collectFolderSubtreeIds( - tx: DbOrTx, - workspaceId: string, - resourceType: FolderResourceType, - folderId: string -): Promise { - const activeFolders = await tx - .select({ id: folderTable.id, parentId: folderTable.parentId }) - .from(folderTable) - .where( - and( - eq(folderTable.workspaceId, workspaceId), - eq(folderTable.resourceType, resourceType), - isNull(folderTable.deletedAt) - ) - ) - - return [folderId, ...collectDescendantFolderIds(activeFolders, folderId)] -} - -/** - * Soft-deletes every folder in `folderIds` and every resource contained by them, stamping - * one shared `timestamp` across the whole cascade. - * - * The shared timestamp is load-bearing: {@link restoreFolderCascade} resurrects only rows - * whose soft-delete timestamp matches the folder's exactly, which is what stops a restore - * from also reviving siblings that were deleted independently before or after. - * - * Callers must already hold row locks on `folderIds` (see the `FOR UPDATE` in the delete - * orchestration) — this function performs no locking of its own. - */ -export async function archiveFolderCascade( - tx: DbOrTx, - config: FolderResourceConfig, - workspaceId: string, - folderIds: string[], - timestamp: Date -): Promise { - const archivedFolders = await tx - .update(folderTable) - .set({ deletedAt: timestamp, updatedAt: timestamp }) - .where( - and( - inArray(folderTable.id, folderIds), - eq(folderTable.workspaceId, workspaceId), - eq(folderTable.resourceType, config.resourceType), - isNull(folderTable.deletedAt) - ) - ) - .returning({ id: folderTable.id }) - - const archivedChildren = await tx - .update(config.table) - .set({ [config.deletedKey]: timestamp, updatedAt: timestamp }) - .where(activeChildFilter(config, workspaceId, inArray(config.folderIdColumn, folderIds) as SQL)) - .returning({ id: config.idColumn }) - - return { folders: archivedFolders.length, children: archivedChildren.length } -} - -/** - * Restores the subtree rooted at `folderId`, resurrecting only folders and resources whose - * soft-delete timestamp equals `timestamp` — i.e. exactly what was archived together. - * - * Walks children explicitly rather than reusing {@link collectFolderSubtreeIds}, which only - * sees *active* folders and by definition cannot see an archived subtree. `seen` guards - * against a parent cycle turning the walk into infinite recursion. - */ -export async function restoreFolderCascade( - tx: DbOrTx, - config: FolderResourceConfig, - workspaceId: string, - folderId: string, - timestamp: Date -): Promise { - const counts: FolderCascadeCounts = { folders: 0, children: 0 } - const seen = new Set() - - const restoreSubtree = async (currentFolderId: string): Promise => { - if (seen.has(currentFolderId)) return - seen.add(currentFolderId) - - const restoredChildren = await tx - .update(config.table) - .set({ [config.deletedKey]: null, updatedAt: new Date() }) - .where( - and( - eq(config.folderIdColumn, currentFolderId), - eq(config.workspaceColumn, workspaceId), - eq(config.deletedColumn, timestamp), - config.scope - ) as SQL - ) - .returning({ id: config.idColumn }) - counts.children += restoredChildren.length - - const archivedChildFolders = await tx - .select({ id: folderTable.id }) - .from(folderTable) - .where( - and( - eq(folderTable.parentId, currentFolderId), - eq(folderTable.workspaceId, workspaceId), - eq(folderTable.resourceType, config.resourceType), - eq(folderTable.deletedAt, timestamp) - ) - ) - - for (const child of archivedChildFolders) { - // Re-assert the timestamp on the write: the read above is unlocked, so a concurrent - // restore could have already claimed this row. An empty result means someone else - // won, and this branch is skipped rather than double-counted. - const [restored] = await tx - .update(folderTable) - .set({ deletedAt: null, updatedAt: new Date() }) - .where(and(eq(folderTable.id, child.id), eq(folderTable.deletedAt, timestamp))) - .returning({ id: folderTable.id }) - if (!restored) continue - counts.folders += 1 - await restoreSubtree(child.id) - } - } - - await restoreSubtree(folderId) - return counts -} - -/** - * Maps internal cascade counts onto the per-resourceType shape the API returns, so a - * `knowledge_base` folder reports `knowledgeBases` and a `table` folder reports `tables`. - */ -export function toCascadeCounts( - resourceType: FolderResourceType, - counts: FolderCascadeCounts -): { folders: number } & Partial> { - return { folders: counts.folders, [folderResourceConfig(resourceType).countKey]: counts.children } -} diff --git a/apps/sim/lib/folders/config.ts b/apps/sim/lib/folders/config.ts deleted file mode 100644 index 0f51ac78765..00000000000 --- a/apps/sim/lib/folders/config.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { knowledgeBase, userTableDefinitions, workflow, workspaceFiles } from '@sim/db' -import { eq, type SQL } from 'drizzle-orm' -import type { PgColumn, PgTable } from 'drizzle-orm/pg-core' -import type { FolderResourceType } from '@/lib/api/contracts/folders' - -/** - * Counts of cascaded resources returned by a folder delete/restore, keyed per resource - * type so a caller can render "3 workflows" vs "3 tables" without inspecting the folder. - */ -export type FolderChildCountKey = 'workflows' | 'files' | 'knowledgeBases' | 'tables' - -/** - * Everything that differs between the four folder-bearing resource types, expressed as - * data. The folder engine in `lib/folders/orchestration.ts` reads this instead of - * branching on `resourceType`, so create/update/delete/restore/reorder each exist exactly - * once and adding a fifth foldered resource means adding one entry here. - * - * This is the deliberate departure from the original generic-folders prototype, which - * dispatched `resourceType` to four separate backends (workflow and file folders still - * delegated to their legacy managers). That shape forced an adapter to reshape records, - * matching on error *strings* to classify failures, and left two writers for one table. - */ -export interface FolderResourceConfig { - resourceType: FolderResourceType - /** Human-readable noun used in audit-log descriptions. */ - label: string - countKey: FolderChildCountKey - /** Table holding the resources that live *inside* folders of this type. */ - table: PgTable - idColumn: PgColumn - folderIdColumn: PgColumn - workspaceColumn: PgColumn - /** Soft-delete timestamp column; the resource is active while this is null. */ - deletedColumn: PgColumn - /** - * Property key backing {@link deletedColumn}. Drizzle's `.set()` takes TypeScript - * property names while `.where()` takes column objects, so the cascade needs both. - * These genuinely differ per table (`archivedAt` vs `deletedAt`), which is exactly the - * delta this config exists to capture. - */ - deletedKey: 'deletedAt' | 'archivedAt' - /** Narrows which rows of `table` participate in folder membership at all. */ - scope?: SQL -} - -export const FOLDER_RESOURCES: Record = { - workflow: { - resourceType: 'workflow', - label: 'workflow', - countKey: 'workflows', - table: workflow, - idColumn: workflow.id, - folderIdColumn: workflow.folderId, - workspaceColumn: workflow.workspaceId, - deletedColumn: workflow.archivedAt, - deletedKey: 'archivedAt', - }, - file: { - resourceType: 'file', - label: 'file', - countKey: 'files', - table: workspaceFiles, - idColumn: workspaceFiles.id, - folderIdColumn: workspaceFiles.folderId, - workspaceColumn: workspaceFiles.workspaceId, - deletedColumn: workspaceFiles.deletedAt, - deletedKey: 'deletedAt', - // `workspace_files` also stores copilot/chat/execution artifacts and profile - // pictures; only files surfaced on the Files page live in folders. - scope: eq(workspaceFiles.context, 'workspace'), - }, - knowledge_base: { - resourceType: 'knowledge_base', - label: 'knowledge base', - countKey: 'knowledgeBases', - table: knowledgeBase, - idColumn: knowledgeBase.id, - folderIdColumn: knowledgeBase.folderId, - workspaceColumn: knowledgeBase.workspaceId, - deletedColumn: knowledgeBase.deletedAt, - deletedKey: 'deletedAt', - }, - table: { - resourceType: 'table', - label: 'table', - countKey: 'tables', - table: userTableDefinitions, - idColumn: userTableDefinitions.id, - folderIdColumn: userTableDefinitions.folderId, - workspaceColumn: userTableDefinitions.workspaceId, - deletedColumn: userTableDefinitions.archivedAt, - deletedKey: 'archivedAt', - }, -} - -export function folderResourceConfig(resourceType: FolderResourceType): FolderResourceConfig { - return FOLDER_RESOURCES[resourceType] -} diff --git a/apps/sim/lib/folders/queries.ts b/apps/sim/lib/folders/queries.ts index 43835aac503..dfc6062150b 100644 --- a/apps/sim/lib/folders/queries.ts +++ b/apps/sim/lib/folders/queries.ts @@ -26,13 +26,6 @@ export async function listFoldersForWorkspace( scope: FolderQueryScope, resourceType: FolderResourceType = 'workflow' ): Promise { - // Only workflow folders have been cut over to the `folder` table. File folders are still - // written to `workspace_file_folders` by `uploads/contexts/workspace`, so the `file` rows - // the migration backfilled here are a frozen snapshot — serving them would return stale - // data. `knowledge_base` and `table` folders have no writer at all yet. Returning empty is - // correct for those until each type's writers move over. - if (resourceType !== 'workflow') return [] - const scopeFilter = scope === 'archived' ? isNotNull(folder.deletedAt) : isNull(folder.deletedAt) const rows = await db diff --git a/apps/sim/lib/logs/folder-expansion.ts b/apps/sim/lib/logs/folder-expansion.ts index 6017c44f875..4fdbbf5f1b9 100644 --- a/apps/sim/lib/logs/folder-expansion.ts +++ b/apps/sim/lib/logs/folder-expansion.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { folder as workflowFolder } from '@sim/db/schema' +import { folder as folderTable } from '@sim/db/schema' import { and, eq, isNull } from 'drizzle-orm' /** @@ -23,13 +23,13 @@ export async function expandFolderIdsWithDescendants( if (seedIds.length === 0) return folderIdsCsv const rows = await db - .select({ id: workflowFolder.id, parentId: workflowFolder.parentId }) - .from(workflowFolder) + .select({ id: folderTable.id, parentId: folderTable.parentId }) + .from(folderTable) .where( and( - eq(workflowFolder.workspaceId, workspaceId), - eq(workflowFolder.resourceType, 'workflow'), - isNull(workflowFolder.deletedAt) + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, 'workflow'), + isNull(folderTable.deletedAt) ) ) diff --git a/apps/sim/lib/workflows/lifecycle.ts b/apps/sim/lib/workflows/lifecycle.ts index 7f22db060fe..a59db6ae5d4 100644 --- a/apps/sim/lib/workflows/lifecycle.ts +++ b/apps/sim/lib/workflows/lifecycle.ts @@ -2,10 +2,10 @@ import { db } from '@sim/db' import { apiKey, chat, + folder as folderTable, webhook, workflow, workflowDeploymentVersion, - folder as workflowFolder, workflowMcpTool, workflowSchedule, workspace, @@ -240,9 +240,11 @@ export async function restoreWorkflow( let clearFolderId = false if (existingWorkflow.folderId) { const [folder] = await db - .select({ archivedAt: workflowFolder.deletedAt }) - .from(workflowFolder) - .where(eq(workflowFolder.id, existingWorkflow.folderId)) + .select({ archivedAt: folderTable.deletedAt }) + .from(folderTable) + .where( + and(eq(folderTable.id, existingWorkflow.folderId), eq(folderTable.resourceType, 'workflow')) + ) if (!folder || folder.archivedAt) { clearFolderId = true diff --git a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts index 99968c93ef6..1d05e8f382c 100644 --- a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts @@ -2,9 +2,9 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { chat, + folder as folderTable, webhook, workflow, - folder as workflowFolder, workflowMcpTool, workflowSchedule, } from '@sim/db/schema' @@ -31,7 +31,7 @@ export interface PerformCreateFolderResult { success: boolean error?: string errorCode?: OrchestrationErrorCode - folder?: typeof workflowFolder.$inferSelect + folder?: typeof folderTable.$inferSelect } export interface PerformUpdateFolderParams { @@ -50,7 +50,7 @@ export interface PerformUpdateFolderResult { success: boolean error?: string errorCode?: OrchestrationErrorCode - folder?: typeof workflowFolder.$inferSelect + folder?: typeof folderTable.$inferSelect } /** @@ -66,11 +66,11 @@ async function assertParentFolderInWorkspace( ): Promise<{ error: string; errorCode: OrchestrationErrorCode } | null> { const [parent] = await db .select({ - workspaceId: workflowFolder.workspaceId, - archivedAt: workflowFolder.deletedAt, + workspaceId: folderTable.workspaceId, + archivedAt: folderTable.deletedAt, }) - .from(workflowFolder) - .where(eq(workflowFolder.id, parentId)) + .from(folderTable) + .where(and(eq(folderTable.id, parentId), eq(folderTable.resourceType, 'workflow'))) .limit(1) if (!parent || parent.workspaceId !== workspaceId || parent.archivedAt) { @@ -85,20 +85,20 @@ async function nextFolderSortOrder( parentId: string | null | undefined ): Promise { const folderParentCondition = parentId - ? eq(workflowFolder.parentId, parentId) - : isNull(workflowFolder.parentId) + ? eq(folderTable.parentId, parentId) + : isNull(folderTable.parentId) const workflowParentCondition = parentId ? eq(workflow.folderId, parentId) : isNull(workflow.folderId) const [[folderResult], [workflowResult]] = await Promise.all([ db - .select({ minSortOrder: min(workflowFolder.sortOrder) }) - .from(workflowFolder) + .select({ minSortOrder: min(folderTable.sortOrder) }) + .from(folderTable) .where( and( - eq(workflowFolder.workspaceId, workspaceId), - eq(workflowFolder.resourceType, 'workflow'), + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, 'workflow'), folderParentCondition ) ), @@ -144,7 +144,7 @@ export async function performCreateFolder( : await nextFolderSortOrder(params.workspaceId, parentId) const [folder] = await db - .insert(workflowFolder) + .insert(folderTable) .values({ id: folderId, resourceType: 'workflow', @@ -212,13 +212,13 @@ export async function performUpdateFolder( if (params.sortOrder !== undefined) updates.sortOrder = params.sortOrder const [folder] = await db - .update(workflowFolder) + .update(folderTable) .set(updates) .where( and( - eq(workflowFolder.id, params.folderId), - eq(workflowFolder.workspaceId, params.workspaceId), - eq(workflowFolder.resourceType, 'workflow') + eq(folderTable.id, params.folderId), + eq(folderTable.workspaceId, params.workspaceId), + eq(folderTable.resourceType, 'workflow') ) ) .returning() @@ -250,14 +250,14 @@ async function deleteFolderRecursively( const stats = { folders: 0, workflows: 0 } const childFolders = await db - .select({ id: workflowFolder.id }) - .from(workflowFolder) + .select({ id: folderTable.id }) + .from(folderTable) .where( and( - eq(workflowFolder.parentId, folderId), - eq(workflowFolder.workspaceId, workspaceId), - eq(workflowFolder.resourceType, 'workflow'), - isNull(workflowFolder.deletedAt) + eq(folderTable.parentId, folderId), + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, 'workflow'), + isNull(folderTable.deletedAt) ) ) @@ -288,9 +288,9 @@ async function deleteFolderRecursively( } await db - .update(workflowFolder) + .update(folderTable) .set({ deletedAt: timestamp }) - .where(eq(workflowFolder.id, folderId)) + .where(and(eq(folderTable.id, folderId), eq(folderTable.resourceType, 'workflow'))) stats.folders += 1 return stats @@ -319,14 +319,14 @@ async function countWorkflowsInFolderRecursively( count += workflowsInFolder.length const childFolders = await db - .select({ id: workflowFolder.id }) - .from(workflowFolder) + .select({ id: folderTable.id }) + .from(folderTable) .where( and( - eq(workflowFolder.parentId, folderId), - eq(workflowFolder.workspaceId, workspaceId), - eq(workflowFolder.resourceType, 'workflow'), - isNull(workflowFolder.deletedAt) + eq(folderTable.parentId, folderId), + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, 'workflow'), + isNull(folderTable.deletedAt) ) ) @@ -414,7 +414,10 @@ async function restoreFolderRecursively( ): Promise<{ folders: number; workflows: number }> { const stats = { folders: 0, workflows: 0 } - await tx.update(workflowFolder).set({ deletedAt: null }).where(eq(workflowFolder.id, folderId)) + await tx + .update(folderTable) + .set({ deletedAt: null }) + .where(and(eq(folderTable.id, folderId), eq(folderTable.resourceType, 'workflow'))) stats.folders += 1 const archivedWorkflows = await tx @@ -449,14 +452,14 @@ async function restoreFolderRecursively( } const archivedChildren = await tx - .select({ id: workflowFolder.id }) - .from(workflowFolder) + .select({ id: folderTable.id }) + .from(folderTable) .where( and( - eq(workflowFolder.parentId, folderId), - eq(workflowFolder.workspaceId, workspaceId), - eq(workflowFolder.resourceType, 'workflow'), - eq(workflowFolder.deletedAt, folderArchivedAt) + eq(folderTable.parentId, folderId), + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, 'workflow'), + eq(folderTable.deletedAt, folderArchivedAt) ) ) @@ -495,12 +498,12 @@ export async function performRestoreFolder( const [folder] = await db .select() - .from(workflowFolder) + .from(folderTable) .where( and( - eq(workflowFolder.id, folderId), - eq(workflowFolder.workspaceId, workspaceId), - eq(workflowFolder.resourceType, 'workflow') + eq(folderTable.id, folderId), + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, 'workflow') ) ) @@ -521,15 +524,15 @@ export async function performRestoreFolder( const restoredStats = await db.transaction(async (tx) => { if (folder.parentId) { const [parentFolder] = await tx - .select({ archivedAt: workflowFolder.deletedAt }) - .from(workflowFolder) - .where(eq(workflowFolder.id, folder.parentId)) + .select({ archivedAt: folderTable.deletedAt }) + .from(folderTable) + .where(and(eq(folderTable.id, folder.parentId), eq(folderTable.resourceType, 'workflow'))) if (!parentFolder || parentFolder.archivedAt) { await tx - .update(workflowFolder) + .update(folderTable) .set({ parentId: null }) - .where(eq(workflowFolder.id, folderId)) + .where(and(eq(folderTable.id, folderId), eq(folderTable.resourceType, 'workflow'))) } } diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts index 128117cbaec..9ccee7d5171 100644 --- a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts @@ -1,6 +1,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { workflow, folder as workflowFolder } from '@sim/db/schema' +import { folder as folderTable, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { isFolderInWorkspace } from '@sim/platform-authz/workflow' import { toError } from '@sim/utils/errors' @@ -121,8 +121,8 @@ async function nextWorkflowSortOrder( ? eq(workflow.folderId, folderId) : isNull(workflow.folderId) const folderParentCondition = folderId - ? eq(workflowFolder.parentId, folderId) - : isNull(workflowFolder.parentId) + ? eq(folderTable.parentId, folderId) + : isNull(folderTable.parentId) const [[workflowMinResult], [folderMinResult]] = await Promise.all([ db @@ -136,12 +136,12 @@ async function nextWorkflowSortOrder( ) ), db - .select({ minOrder: min(workflowFolder.sortOrder) }) - .from(workflowFolder) + .select({ minOrder: min(folderTable.sortOrder) }) + .from(folderTable) .where( and( - eq(workflowFolder.workspaceId, workspaceId), - eq(workflowFolder.resourceType, 'workflow'), + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, 'workflow'), folderParentCondition ) ), diff --git a/apps/sim/lib/workflows/persistence/duplicate.ts b/apps/sim/lib/workflows/persistence/duplicate.ts index 82942ad19d9..3d4600e9811 100644 --- a/apps/sim/lib/workflows/persistence/duplicate.ts +++ b/apps/sim/lib/workflows/persistence/duplicate.ts @@ -1,9 +1,9 @@ import { db } from '@sim/db' import { + folder as folderTable, workflow, workflowBlocks, workflowEdges, - folder as workflowFolder, workflowSubflows, } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -73,14 +73,14 @@ async function assertTargetFolderMutable( visited.add(currentFolderId) const [folder] = await tx .select({ - id: workflowFolder.id, - parentId: workflowFolder.parentId, - workspaceId: workflowFolder.workspaceId, - locked: workflowFolder.locked, - archivedAt: workflowFolder.deletedAt, + id: folderTable.id, + parentId: folderTable.parentId, + workspaceId: folderTable.workspaceId, + locked: folderTable.locked, + archivedAt: folderTable.deletedAt, }) - .from(workflowFolder) - .where(eq(workflowFolder.id, currentFolderId)) + .from(folderTable) + .where(and(eq(folderTable.id, currentFolderId), eq(folderTable.resourceType, 'workflow'))) .limit(1) if (!folder || folder.workspaceId !== targetWorkspaceId || folder.archivedAt) { @@ -182,8 +182,8 @@ export async function duplicateWorkflow( ? eq(workflow.folderId, targetFolderId) : isNull(workflow.folderId) const folderParentCondition = targetFolderId - ? eq(workflowFolder.parentId, targetFolderId) - : isNull(workflowFolder.parentId) + ? eq(folderTable.parentId, targetFolderId) + : isNull(folderTable.parentId) const [[workflowMinResult], [folderMinResult]] = await Promise.all([ tx @@ -191,12 +191,12 @@ export async function duplicateWorkflow( .from(workflow) .where(and(eq(workflow.workspaceId, targetWorkspaceId), workflowParentCondition)), tx - .select({ minOrder: min(workflowFolder.sortOrder) }) - .from(workflowFolder) + .select({ minOrder: min(folderTable.sortOrder) }) + .from(folderTable) .where( and( - eq(workflowFolder.workspaceId, targetWorkspaceId), - eq(workflowFolder.resourceType, 'workflow'), + eq(folderTable.workspaceId, targetWorkspaceId), + eq(folderTable.resourceType, 'workflow'), folderParentCondition ) ), diff --git a/apps/sim/lib/workflows/utils.ts b/apps/sim/lib/workflows/utils.ts index 1b492f3a282..682a736d951 100644 --- a/apps/sim/lib/workflows/utils.ts +++ b/apps/sim/lib/workflows/utils.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { folder as workflowFolder, workflow as workflowTable } from '@sim/db/schema' +import { folder as folderTable, workflow as workflowTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { generateId } from '@sim/utils/id' @@ -401,8 +401,8 @@ export async function createWorkflowRecord(params: CreateWorkflowInput) { ? eq(workflowTable.folderId, folderId) : isNull(workflowTable.folderId) const folderParentCondition = folderId - ? eq(workflowFolder.parentId, folderId) - : isNull(workflowFolder.parentId) + ? eq(folderTable.parentId, folderId) + : isNull(folderTable.parentId) const [[workflowMinResult], [folderMinResult]] = await Promise.all([ db @@ -416,12 +416,12 @@ export async function createWorkflowRecord(params: CreateWorkflowInput) { ) ), db - .select({ minOrder: min(workflowFolder.sortOrder) }) - .from(workflowFolder) + .select({ minOrder: min(folderTable.sortOrder) }) + .from(folderTable) .where( and( - eq(workflowFolder.workspaceId, workspaceId), - eq(workflowFolder.resourceType, 'workflow'), + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, 'workflow'), folderParentCondition ) ), @@ -492,31 +492,28 @@ export async function setWorkflowVariables(workflowId: string, variables: Record // ── Folder CRUD ── -export interface CreateFolderInput { - userId: string - workspaceId: string - name: string - parentId?: string | null -} - export async function verifyFolderWorkspace( folderId: string, workspaceId: string ): Promise { const [row] = await db - .select({ id: workflowFolder.id }) - .from(workflowFolder) + .select({ id: folderTable.id }) + .from(folderTable) .where( and( - eq(workflowFolder.id, folderId), - eq(workflowFolder.workspaceId, workspaceId), - eq(workflowFolder.resourceType, 'workflow') + eq(folderTable.id, folderId), + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, 'workflow') ) ) .limit(1) return Boolean(row) } +/** + * Walks the parent chain upward from `parentId` to check whether re-parenting `folderId` + * under it would form a cycle. Returns true when a cycle would be created. + */ export async function checkForCircularReference( folderId: string, parentId: string @@ -532,9 +529,9 @@ export async function checkForCircularReference( visited.add(currentParentId) const [parent] = await db - .select({ parentId: workflowFolder.parentId }) - .from(workflowFolder) - .where(eq(workflowFolder.id, currentParentId)) + .select({ parentId: folderTable.parentId }) + .from(folderTable) + .where(and(eq(folderTable.id, currentParentId), eq(folderTable.resourceType, 'workflow'))) .limit(1) currentParentId = parent?.parentId || null @@ -546,19 +543,19 @@ export async function checkForCircularReference( export async function listFolders(workspaceId: string) { return db .select({ - folderId: workflowFolder.id, - folderName: workflowFolder.name, - parentId: workflowFolder.parentId, - sortOrder: workflowFolder.sortOrder, - locked: workflowFolder.locked, + folderId: folderTable.id, + folderName: folderTable.name, + parentId: folderTable.parentId, + sortOrder: folderTable.sortOrder, + locked: folderTable.locked, }) - .from(workflowFolder) + .from(folderTable) .where( and( - eq(workflowFolder.workspaceId, workspaceId), - eq(workflowFolder.resourceType, 'workflow'), - isNull(workflowFolder.deletedAt) + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, 'workflow'), + isNull(folderTable.deletedAt) ) ) - .orderBy(asc(workflowFolder.sortOrder), asc(workflowFolder.createdAt)) + .orderBy(asc(folderTable.sortOrder), asc(folderTable.createdAt)) } diff --git a/packages/platform-authz/src/workflow.ts b/packages/platform-authz/src/workflow.ts index e238a45cf34..7e50f880a2c 100644 --- a/packages/platform-authz/src/workflow.ts +++ b/packages/platform-authz/src/workflow.ts @@ -1,4 +1,4 @@ -import { db, workflow, folder as workflowFolder, workspace } from '@sim/db' +import { db, folder as folderTable, workflow, workspace } from '@sim/db' import { and, eq, isNull } from 'drizzle-orm' import { type PermissionType, @@ -107,12 +107,18 @@ export async function getFolderLockStatus(folderId: string | null): Promise Date: Tue, 28 Jul 2026 16:30:44 -0700 Subject: [PATCH 4/7] fix(folders): stop the update path writing dropped columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit performUpdateFolder still copied color and isExpanded into its update payload after the cutover, so a caller passing either wrote against columns the generic folder table does not have. The create path had already dropped them. Typed the payload as Partial instead of Record — the loose type is the reason typecheck caught every other dropped-column reference in this cutover but not this one. --- apps/sim/lib/workflows/orchestration/folder-lifecycle.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts index 1d05e8f382c..bb695fd2de6 100644 --- a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts @@ -39,8 +39,6 @@ export interface PerformUpdateFolderParams { workspaceId: string userId: string name?: string - color?: string - isExpanded?: boolean locked?: boolean parentId?: string | null sortOrder?: number @@ -203,10 +201,10 @@ export async function performUpdateFolder( } } - const updates: Record = { updatedAt: new Date() } + // Typed against the table rather than `Record`: the loose type is what + // let `color`/`isExpanded` survive the cutover here after the create path dropped them. + const updates: Partial = { updatedAt: new Date() } if (params.name !== undefined) updates.name = params.name.trim() - if (params.color !== undefined) updates.color = params.color - if (params.isExpanded !== undefined) updates.isExpanded = params.isExpanded if (params.locked !== undefined) updates.locked = params.locked if (params.parentId !== undefined) updates.parentId = params.parentId || null if (params.sortOrder !== undefined) updates.sortOrder = params.sortOrder From c0870127145ef0c803a0f074f94c914a8871e59f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 16:44:31 -0700 Subject: [PATCH 5/7] fix(folders): return 409 instead of 500 on duplicate folder names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generic folder table carries a partial unique index on active (workspaceId, resourceType, parentId, name) that workflow_folder never had, so duplicate sibling names are newly rejectable — and prod has 35 such groups today, deduped only by the migration backfill. Create, update and restore all reached the constraint with no 23505 handling: a duplicate name surfaced as an unhandled 500. Restore had no try/catch at all, and it is the easiest one to hit, since clearing deletedAt brings a row back under the index against siblings that may have taken the name meanwhile. All three now map the violation to a conflict the caller can act on. --- .../orchestration/folder-lifecycle.ts | 65 ++++++++++++++----- 1 file changed, 50 insertions(+), 15 deletions(-) diff --git a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts index bb695fd2de6..b7741e30dee 100644 --- a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts @@ -9,6 +9,7 @@ import { workflowSchedule, } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, inArray, isNull, min } from 'drizzle-orm' import { archiveWorkflowsByIdsInWorkspace } from '@/lib/workflows/lifecycle' @@ -174,6 +175,18 @@ export async function performCreateFolder( return { success: true, folder } } catch (error) { + // `folder` carries a unique index on (workspaceId, resourceType, parent, name) for active + // rows that `workflow_folder` never had, so a duplicate sibling name is newly rejectable + // here. Map it to a 409 rather than letting it surface as a 500 — the client-side dedup + // in useFolderCreateWithDedup is best-effort and races, and the copilot/import paths + // create folders by name. + if (getPostgresErrorCode(error) === '23505') { + return { + success: false, + error: 'A folder with this name already exists in this location', + errorCode: 'conflict', + } + } logger.error('Failed to create workflow folder', { error }) return { success: false, error: 'Internal server error', errorCode: 'internal' } } @@ -229,6 +242,13 @@ export async function performUpdateFolder( return { success: true, folder } } catch (error) { + if (getPostgresErrorCode(error) === '23505') { + return { + success: false, + error: 'A folder with this name already exists in this location', + errorCode: 'conflict', + } + } logger.error('Failed to update workflow folder', { error }) return { success: false, error: 'Internal server error', errorCode: 'internal' } } @@ -519,23 +539,38 @@ export async function performRestoreFolder( return { success: false, error: 'Cannot restore folder into an archived workspace' } } - const restoredStats = await db.transaction(async (tx) => { - if (folder.parentId) { - const [parentFolder] = await tx - .select({ archivedAt: folderTable.deletedAt }) - .from(folderTable) - .where(and(eq(folderTable.id, folder.parentId), eq(folderTable.resourceType, 'workflow'))) - - if (!parentFolder || parentFolder.archivedAt) { - await tx - .update(folderTable) - .set({ parentId: null }) - .where(and(eq(folderTable.id, folderId), eq(folderTable.resourceType, 'workflow'))) + let restoredStats: { folders: number; workflows: number } + try { + restoredStats = await db.transaction(async (tx) => { + if (folder.parentId) { + const [parentFolder] = await tx + .select({ archivedAt: folderTable.deletedAt }) + .from(folderTable) + .where(and(eq(folderTable.id, folder.parentId), eq(folderTable.resourceType, 'workflow'))) + + if (!parentFolder || parentFolder.archivedAt) { + await tx + .update(folderTable) + .set({ parentId: null }) + .where(and(eq(folderTable.id, folderId), eq(folderTable.resourceType, 'workflow'))) + } } - } - return restoreFolderRecursively(folderId, workspaceId, folder.deletedAt!, tx) - }) + return restoreFolderRecursively(folderId, workspaceId, folder.deletedAt!, tx) + }) + } catch (error) { + // Restoring clears `deletedAt`, which brings the row back under the generic table's + // partial unique index on active (workspaceId, resourceType, parent, name) — a + // constraint `workflow_folder` never had. If a sibling has since taken the name, report + // it as a conflict rather than a 500, so the caller can rename and retry. + if (getPostgresErrorCode(error) === '23505') { + return { + success: false, + error: 'A folder with this name already exists in this location', + } + } + throw error + } logger.info('Restored folder and all contents:', { folderId, restoredStats }) From 5184e03e46c5d0787e5999ad98f0dcdb57170f0e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 16:48:54 -0700 Subject: [PATCH 6/7] improvement(folders): suffix the name on restore instead of failing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 409 is the right answer when the user is choosing a name — create and rename keep it. Restore is different: the caller cannot rename an archived folder, so refusing on a name a sibling has since taken leaves them permanently unable to restore it, with no affordance to resolve the conflict. Restore now deduplicates to " (N)" instead, matching the convention already used by the duplicate route, the client-side create dedup, and the 0272 backfill. The rename happens while the row is still archived, which cannot collide because the unique index only covers active rows. Only the restore root can conflict — descendants come back alongside the siblings they were archived with. Extracts deduplicateFolderName out of the duplicate route into lib/folders/naming.ts now that it has a second caller. --- .../app/api/folders/[id]/duplicate/route.ts | 33 +------------ apps/sim/lib/folders/naming.ts | 46 +++++++++++++++++++ .../orchestration/folder-lifecycle.ts | 22 +++++++++ 3 files changed, 69 insertions(+), 32 deletions(-) create mode 100644 apps/sim/lib/folders/naming.ts diff --git a/apps/sim/app/api/folders/[id]/duplicate/route.ts b/apps/sim/app/api/folders/[id]/duplicate/route.ts index 70f247c7009..d846ebdf3f8 100644 --- a/apps/sim/app/api/folders/[id]/duplicate/route.ts +++ b/apps/sim/app/api/folders/[id]/duplicate/route.ts @@ -12,6 +12,7 @@ import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { DbOrTx } from '@/lib/db/types' +import { deduplicateFolderName } from '@/lib/folders/naming' import { toFolderApi } from '@/lib/folders/queries' import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -265,38 +266,6 @@ async function assertTargetParentFolderMutable( } } -async function deduplicateFolderName( - tx: DbOrTx, - workspaceId: string, - parentId: string | null, - requestedName: string -): Promise { - const parentCondition = parentId - ? eq(folderTable.parentId, parentId) - : isNull(folderTable.parentId) - const siblingRows = await tx - .select({ name: folderTable.name }) - .from(folderTable) - .where( - and( - eq(folderTable.workspaceId, workspaceId), - eq(folderTable.resourceType, 'workflow'), - parentCondition, - isNull(folderTable.deletedAt) - ) - ) - const siblingNames = new Set(siblingRows.map((row) => row.name)) - if (!siblingNames.has(requestedName)) return requestedName - - let suffix = 1 - let candidate = `${requestedName} (${suffix})` - while (siblingNames.has(candidate)) { - suffix += 1 - candidate = `${requestedName} (${suffix})` - } - return candidate -} - async function duplicateFolderStructure( tx: DbOrTx, sourceFolderId: string, diff --git a/apps/sim/lib/folders/naming.ts b/apps/sim/lib/folders/naming.ts new file mode 100644 index 00000000000..f6b5f2a4de0 --- /dev/null +++ b/apps/sim/lib/folders/naming.ts @@ -0,0 +1,46 @@ +import { type db, folder as folderTable } from '@sim/db' +import { and, eq, isNull } from 'drizzle-orm' +import type { FolderResourceType } from '@/lib/api/contracts/folders' + +type DbOrTx = Pick + +/** + * Returns `requestedName`, or the first `" (N)"` variant not already taken by an + * active sibling under `parentId`. + * + * The generic `folder` table has a partial unique index on active + * `(workspaceId, resourceType, parentId, name)`, so any path that makes a row active with a + * caller-supplied name has to either dedup here or handle a 23505. Use this where the user + * has no opportunity to choose a different name (duplicate, restore); return a conflict + * instead where they do (create, rename). + * + * The `" (N)"` shape deliberately matches both the client-side dedup in + * `useFolderCreateWithDedup` and the backfill in migration 0272, so a deduped name reads the + * same however it was produced. + */ +export async function deduplicateFolderName( + tx: DbOrTx, + workspaceId: string, + parentId: string | null, + requestedName: string, + resourceType: FolderResourceType = 'workflow' +): Promise { + const siblingRows = await tx + .select({ name: folderTable.name }) + .from(folderTable) + .where( + and( + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, resourceType), + parentId ? eq(folderTable.parentId, parentId) : isNull(folderTable.parentId), + isNull(folderTable.deletedAt) + ) + ) + + const siblingNames = new Set(siblingRows.map((row) => row.name)) + if (!siblingNames.has(requestedName)) return requestedName + + let suffix = 1 + while (siblingNames.has(`${requestedName} (${suffix})`)) suffix += 1 + return `${requestedName} (${suffix})` +} diff --git a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts index b7741e30dee..dfb63363dc6 100644 --- a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts @@ -12,6 +12,7 @@ import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, inArray, isNull, min } from 'drizzle-orm' +import { deduplicateFolderName } from '@/lib/folders/naming' import { archiveWorkflowsByIdsInWorkspace } from '@/lib/workflows/lifecycle' import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types' import { checkForCircularReference } from '@/lib/workflows/utils' @@ -556,6 +557,27 @@ export async function performRestoreFolder( } } + // Restore is a recovery action — the caller cannot rename an archived folder, so a + // name already taken by an active sibling would leave them permanently unable to + // restore. Dedup instead of failing. Safe to rename while the row is still archived: + // the unique index only covers active rows, so this cannot collide before the + // recursive restore below clears `deletedAt`. Only the restore root can conflict — + // descendants come back alongside the siblings they were archived with. + const restoredName = await deduplicateFolderName( + tx, + workspaceId, + folder.parentId, + folder.name + ) + if (restoredName !== folder.name) { + logger.info('Renamed folder on restore to avoid a sibling name conflict', { + folderId, + from: folder.name, + to: restoredName, + }) + await tx.update(folderTable).set({ name: restoredName }).where(eq(folderTable.id, folderId)) + } + return restoreFolderRecursively(folderId, workspaceId, folder.deletedAt!, tx) }) } catch (error) { From da7665b1c297960fb38151f36835313032e46276 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 17:05:15 -0700 Subject: [PATCH 7/7] fix(folders): finish the unique-name handling across every write path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gaps left by the previous pass, all from the same new constraint. - restore re-roots a folder whose parent is still archived, but the dedup ran against the original parentId, so a root-level clash was missed and clearing deletedAt still hit the index — defeating the dedup in exactly the case it exists for. It now dedups against the resolved parent - the folder PUT handler mapped only not_found and validation, so the conflict errorCode added for renames fell through to 500. It now shares folderMutationStatus with the POST route - admin workspace import inserted path segments unconditionally, so a segment matching an existing folder failed the import. It now reuses the existing folder (mkdir -p), which is also the semantics importing into a folder path should have — the same path twice lands in one tree --- apps/sim/app/api/folders/[id]/route.ts | 11 ++- .../v1/admin/workspaces/[id]/import/route.ts | 77 ++++++++++++++----- .../orchestration/folder-lifecycle.ts | 6 +- 3 files changed, 70 insertions(+), 24 deletions(-) diff --git a/apps/sim/app/api/folders/[id]/route.ts b/apps/sim/app/api/folders/[id]/route.ts index 960ede19939..60753cb0685 100644 --- a/apps/sim/app/api/folders/[id]/route.ts +++ b/apps/sim/app/api/folders/[id]/route.ts @@ -13,6 +13,14 @@ import { captureServerEvent } from '@/lib/posthog/server' import { performDeleteFolder, performUpdateFolder } from '@/lib/workflows/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +/** Maps an orchestration errorCode to its HTTP status; mirrors the POST /api/folders route. */ +function folderMutationStatus(errorCode: string | undefined): number { + if (errorCode === 'validation') return 400 + if (errorCode === 'conflict') return 409 + if (errorCode === 'not_found') return 404 + return 500 +} + const logger = createLogger('FoldersIDAPI') // PUT - Update a folder @@ -92,8 +100,7 @@ export const PUT = withRouteHandler( }) if (!result.success || !result.folder) { - const status = - result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500 + const status = folderMutationStatus(result.errorCode) return NextResponse.json({ error: result.error }, { status }) } diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts index 25e46fe94ad..378aa8acedc 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts @@ -28,7 +28,7 @@ import { folder as folderTable, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import { NextResponse } from 'next/server' import { adminV1ImportWorkspaceContract, @@ -76,6 +76,51 @@ interface ParsedWorkflow { folderPath: string[] } +/** + * Returns the id of the active workflow folder named `name` under `parentId`, creating it if + * absent — `mkdir -p` semantics. + * + * The generic `folder` table enforces active sibling-name uniqueness, which + * `workflow_folder` did not, so blindly inserting an import path segment that already exists + * now fails the whole import on a unique violation. Reusing the existing folder is also the + * behaviour an import of a folder *path* should have: importing into "Reports/2026" twice + * should land in one tree, not two. + */ +async function ensureImportFolder( + workspaceId: string, + userId: string, + name: string, + parentId: string | null +): Promise { + const [existing] = await db + .select({ id: folderTable.id }) + .from(folderTable) + .where( + and( + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, 'workflow'), + eq(folderTable.name, name), + parentId ? eq(folderTable.parentId, parentId) : isNull(folderTable.parentId), + isNull(folderTable.deletedAt) + ) + ) + .limit(1) + if (existing) return existing.id + + const folderId = generateId() + await db.insert(folderTable).values({ + id: folderId, + resourceType: 'workflow', + name, + userId, + workspaceId, + parentId, + createdAt: new Date(), + updatedAt: new Date(), + }) + return folderId +} + export const POST = withRouteHandler( withAdminAuthParams(async (request, context) => { const parsed = await parseRequest(adminV1ImportWorkspaceContract, request, context) @@ -151,17 +196,12 @@ export const POST = withRouteHandler( let rootFolderId: string | undefined if (rootFolderName && createFolders) { - rootFolderId = generateId() - await db.insert(folderTable).values({ - id: rootFolderId, - resourceType: 'workflow', - name: rootFolderName, - userId: workspaceData.ownerId, + rootFolderId = await ensureImportFolder( workspaceId, - parentId: null, - createdAt: new Date(), - updatedAt: new Date(), - }) + workspaceData.ownerId, + rootFolderName, + null + ) } const folderMap = new Map() @@ -230,17 +270,12 @@ async function importSingleWorkflow( const fullPath = rootFolderId ? `root/${pathSegment}` : pathSegment if (!folderMap.has(fullPath)) { - const folderId = generateId() - await db.insert(folderTable).values({ - id: folderId, - resourceType: 'workflow', - name: wf.folderPath[i], - userId: ownerId, + const folderId = await ensureImportFolder( workspaceId, - parentId, - createdAt: new Date(), - updatedAt: new Date(), - }) + ownerId, + wf.folderPath[i], + parentId + ) folderMap.set(fullPath, folderId) parentId = folderId } else { diff --git a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts index dfb63363dc6..4159442b056 100644 --- a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts @@ -543,6 +543,9 @@ export async function performRestoreFolder( let restoredStats: { folders: number; workflows: number } try { restoredStats = await db.transaction(async (tx) => { + // A folder whose parent is still archived is re-rooted, so the name it has to be + // unique against is its *resolved* parent's sibling set, not its original one. + let resolvedParentId = folder.parentId if (folder.parentId) { const [parentFolder] = await tx .select({ archivedAt: folderTable.deletedAt }) @@ -550,6 +553,7 @@ export async function performRestoreFolder( .where(and(eq(folderTable.id, folder.parentId), eq(folderTable.resourceType, 'workflow'))) if (!parentFolder || parentFolder.archivedAt) { + resolvedParentId = null await tx .update(folderTable) .set({ parentId: null }) @@ -566,7 +570,7 @@ export async function performRestoreFolder( const restoredName = await deduplicateFolderName( tx, workspaceId, - folder.parentId, + resolvedParentId, folder.name ) if (restoredName !== folder.name) {