feat(folders): add the generic resourceType-driven folder engine (#6037)

- Replaces the workflow-hardcoded folder orchestration with a config-driven engine: one implementation parameterized by `FOLDER_RESOURCES`, with `lib/workflows/orchestration/folder-lifecycle.ts` reduced from 620 lines to thin wrappers so no existing caller moved
- Threads `resourceType` through all four folder routes and the React Query keys; contract widened to `workflow | knowledge_base | table`
- Folder locking moved behind a `supportsLocking` capability rather than scattered `resourceType === 'workflow'` checks
- Folder restore now matches dependents by the cascade timestamp, so a schedule or webhook archived independently before a folder delete stays archived (deliberate behavior change, documented in the PR)
- 25 cascade tests including a fixed-statement-count assertion guarding against N+1 regressions
This commit is contained in:
Waleed
2026-07-28 19:29:07 -07:00
committed by GitHub
parent 1d64b92b41
commit 5b7cf99126
31 changed files with 2244 additions and 762 deletions
@@ -112,7 +112,8 @@ export const POST = withRouteHandler(
tx,
targetWorkspaceId,
targetParentId,
name
name,
'workflow'
)
await tx.insert(folderTable).values({
+14 -6
View File
@@ -5,8 +5,9 @@ import { restoreFolderContract } from '@/lib/api/contracts'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { restoreFolder } from '@/lib/folders/lifecycle'
import { folderMutationStatus } from '@/lib/folders/status'
import { captureServerEvent } from '@/lib/posthog/server'
import { performRestoreFolder } from '@/lib/workflows/orchestration/folder-lifecycle'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('RestoreFolderAPI')
@@ -23,29 +24,36 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route
const parsed = await parseRequest(restoreFolderContract, request, context)
if (!parsed.success) return parsed.response
const { id: folderId } = parsed.data.params
const { workspaceId } = parsed.data.body
const { workspaceId, resourceType } = parsed.data.body
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (permission !== 'admin' && permission !== 'write') {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
const result = await performRestoreFolder({
const result = await restoreFolder({
resourceType,
folderId,
workspaceId,
userId: session.user.id,
})
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 })
return NextResponse.json(
{ error: result.error },
{ status: folderMutationStatus(result.errorCode) }
)
}
logger.info(`Restored folder ${folderId}`, { restoredItems: result.restoredItems })
logger.info(`Restored folder ${folderId}`, {
resourceType,
restoredItems: result.restoredItems,
})
captureServerEvent(
session.user.id,
'folder_restored',
{ folder_id: folderId, workspace_id: workspaceId },
{ folder_id: folderId, workspace_id: workspaceId, resource_type: resourceType },
{ groups: { workspace: workspaceId } }
)
+58 -25
View File
@@ -8,16 +8,14 @@ import {
authMockFns,
createMockRequest,
dbChainMockFns,
foldersLifecycleMock,
foldersLifecycleMockFns,
type MockUser,
permissionsMock,
permissionsMockFns,
queueTableRows,
resetDbChainMock,
schemaMock,
workflowsOrchestrationMock,
workflowsOrchestrationMockFns,
workflowsUtilsMock,
workflowsUtilsMockFns,
} from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -36,8 +34,11 @@ const { mockLogger } = vi.hoisted(() => {
}
})
const mockPerformDeleteFolder = workflowsOrchestrationMockFns.mockPerformDeleteFolder
const mockPerformUpdateFolder = workflowsOrchestrationMockFns.mockPerformUpdateFolder
const mockDeleteFolder = foldersLifecycleMockFns.mockDeleteFolder
const mockUpdateFolder = foldersLifecycleMockFns.mockUpdateFolder
/** Parent ids the mocked engine treats as closing a cycle for the folder under test. */
const cyclicParentIds = new Set<string>()
const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions
@@ -48,8 +49,7 @@ vi.mock('@sim/logger', () => ({
getRequestContext: () => undefined,
}))
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock)
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
vi.mock('@/lib/folders/lifecycle', () => foldersLifecycleMock)
import { DELETE, PUT } from '@/app/api/folders/[id]/route'
@@ -101,11 +101,11 @@ describe('Individual Folder API Route', () => {
resetDbChainMock()
mockGetUserEntityPermissions.mockResolvedValue('admin')
mockPerformDeleteFolder.mockResolvedValue({
mockDeleteFolder.mockResolvedValue({
success: true,
deletedItems: { folders: 1, workflows: 0 },
})
mockPerformUpdateFolder.mockImplementation(async (params) => {
mockUpdateFolder.mockImplementation(async (params) => {
if (params.parentId && params.parentId === params.folderId) {
return {
success: false,
@@ -113,13 +113,7 @@ describe('Individual Folder API Route', () => {
errorCode: 'validation',
}
}
if (
params.parentId &&
(await workflowsUtilsMockFns.mockCheckForCircularReference(
params.folderId,
params.parentId
))
) {
if (params.parentId && cyclicParentIds.has(params.parentId)) {
return {
success: false,
error: 'Cannot create circular folder reference',
@@ -140,7 +134,7 @@ describe('Individual Folder API Route', () => {
},
}
})
workflowsUtilsMockFns.mockCheckForCircularReference.mockResolvedValue(false)
cyclicParentIds.clear()
})
describe('PUT /api/folders/[id]', () => {
@@ -250,6 +244,26 @@ describe('Individual Folder API Route', () => {
expect(data).toHaveProperty('folder')
})
it('rejects a locked write on a resource type that has no lock semantics', async () => {
mockAuthenticatedUser()
queueFolderLookup()
const req = createMockRequest(
'PUT',
{ locked: true },
{},
'http://localhost:3000/api/folders/folder-1?resourceType=knowledge_base'
)
const params = Promise.resolve({ id: 'folder-1' })
const response = await PUT(req, { params })
expect(response.status).toBe(400)
const data = await response.json()
expect(data.error).toBe('Folder locking is only supported for workflow folders')
expect(mockUpdateFolder).not.toHaveBeenCalled()
})
it('should return 400 when trying to set folder as its own parent', async () => {
mockAuthenticatedUser()
@@ -368,7 +382,7 @@ describe('Individual Folder API Route', () => {
workspaceId: 'workspace-123',
})
workflowsUtilsMockFns.mockCheckForCircularReference.mockResolvedValue(true)
cyclicParentIds.add('folder-1')
const req = createMockRequest('PUT', {
name: 'Updated Folder 3',
@@ -382,9 +396,8 @@ describe('Individual Folder API Route', () => {
const data = await response.json()
expect(data).toHaveProperty('error', 'Cannot create circular folder reference')
expect(workflowsUtilsMockFns.mockCheckForCircularReference).toHaveBeenCalledWith(
'folder-3',
'folder-1'
expect(mockUpdateFolder).toHaveBeenCalledWith(
expect.objectContaining({ folderId: 'folder-3', parentId: 'folder-1' })
)
})
})
@@ -405,7 +418,8 @@ describe('Individual Folder API Route', () => {
const data = await response.json()
expect(data).toHaveProperty('success', true)
expect(data).toHaveProperty('deletedItems')
expect(mockPerformDeleteFolder).toHaveBeenCalledWith({
expect(mockDeleteFolder).toHaveBeenCalledWith({
resourceType: 'workflow',
folderId: 'folder-1',
workspaceId: 'workspace-123',
userId: TEST_USER.id,
@@ -413,6 +427,25 @@ describe('Individual Folder API Route', () => {
})
})
it('surfaces a delete-locked resource as 423, not a generic 500', async () => {
mockAuthenticatedUser()
queueFolderLookup()
mockDeleteFolder.mockResolvedValueOnce({
success: false,
error: 'Cannot delete folder: table Ledger is delete-locked',
errorCode: 'locked',
})
const req = createMockRequest('DELETE')
const params = Promise.resolve({ id: 'folder-1' })
const response = await DELETE(req, { params })
expect(response.status).toBe(423)
const data = await response.json()
expect(data.error).toBe('Cannot delete folder: table Ledger is delete-locked')
})
it('should return 401 for unauthenticated delete requests', async () => {
mockUnauthenticated()
@@ -458,7 +491,7 @@ describe('Individual Folder API Route', () => {
const data = await response.json()
expect(data).toHaveProperty('success', true)
expect(mockPerformDeleteFolder).toHaveBeenCalled()
expect(mockDeleteFolder).toHaveBeenCalled()
})
it('should allow folder deletion for admin permissions', async () => {
@@ -476,7 +509,7 @@ describe('Individual Folder API Route', () => {
const data = await response.json()
expect(data).toHaveProperty('success', true)
expect(mockPerformDeleteFolder).toHaveBeenCalled()
expect(mockDeleteFolder).toHaveBeenCalled()
})
it('should handle database errors during deletion', async () => {
+54 -32
View File
@@ -4,23 +4,18 @@ import { createLogger } from '@sim/logger'
import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { updateFolderContract } from '@/lib/api/contracts'
import { deleteFolderContract, updateFolderContract } from '@/lib/api/contracts'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { HttpError } from '@/lib/core/utils/http-error'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { folderResourceConfig } from '@/lib/folders/config'
import { deleteFolder, updateFolder } from '@/lib/folders/lifecycle'
import { toFolderApi } from '@/lib/folders/queries'
import { folderMutationStatus } from '@/lib/folders/status'
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
@@ -47,13 +42,13 @@ export const PUT = withRouteHandler(
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
const { resourceType } = parsed.data.query
const { name, locked, parentId, sortOrder } = parsed.data.body
// Verify the folder exists
const existingFolder = await db
.select()
.from(folderTable)
.where(and(eq(folderTable.id, id), eq(folderTable.resourceType, 'workflow')))
.where(and(eq(folderTable.id, id), eq(folderTable.resourceType, resourceType)))
.then((rows) => rows[0])
if (!existingFolder) {
@@ -74,22 +69,38 @@ export const PUT = withRouteHandler(
)
}
if (locked !== undefined && workspacePermission !== 'admin') {
// Locking is workflow-only. Reject the field outright for the other types rather than
// dropping it silently, and keep the admin gate and the lock checks behind the same
// capability so a non-workflow folder can neither be 403'd by a field that has no
// meaning for it nor persist a `locked` value nothing will ever read.
const supportsLocking = Boolean(folderResourceConfig(resourceType).supportsLocking)
if (locked !== undefined && !supportsLocking) {
return NextResponse.json(
{ error: 'Admin access required to lock folders' },
{ status: 403 }
{ error: 'Folder locking is only supported for workflow folders' },
{ status: 400 }
)
}
const hasNonLockUpdate = Object.keys(parsed.data.body).some((key) => key !== 'locked')
if (hasNonLockUpdate) {
await assertFolderMutable(id)
}
if (parentId !== undefined) {
await assertFolderMutable(parentId)
if (supportsLocking) {
if (locked !== undefined && workspacePermission !== 'admin') {
return NextResponse.json(
{ error: 'Admin access required to lock folders' },
{ status: 403 }
)
}
const hasNonLockUpdate = Object.keys(parsed.data.body).some((key) => key !== 'locked')
if (hasNonLockUpdate) {
await assertFolderMutable(id)
}
if (parentId !== undefined) {
await assertFolderMutable(parentId)
}
}
const result = await performUpdateFolder({
const result = await updateFolder({
resourceType,
folderId: id,
workspaceId: existingFolder.workspaceId,
userId: session.user.id,
@@ -120,20 +131,22 @@ export const PUT = withRouteHandler(
// DELETE - Delete a folder and all its contents
export const DELETE = withRouteHandler(
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const parsed = await parseRequest(deleteFolderContract, request, context)
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
const { resourceType } = parsed.data.query
// Verify the folder exists
const existingFolder = await db
.select()
.from(folderTable)
.where(and(eq(folderTable.id, id), eq(folderTable.resourceType, 'workflow')))
.where(and(eq(folderTable.id, id), eq(folderTable.resourceType, resourceType)))
.then((rows) => rows[0])
if (!existingFolder) {
@@ -153,9 +166,12 @@ export const DELETE = withRouteHandler(
)
}
await assertFolderMutable(id)
if (folderResourceConfig(resourceType).supportsLocking) {
await assertFolderMutable(id)
}
const result = await performDeleteFolder({
const result = await deleteFolder({
resourceType,
folderId: id,
workspaceId: existingFolder.workspaceId,
userId: session.user.id,
@@ -163,15 +179,16 @@ export const DELETE = withRouteHandler(
})
if (!result.success) {
const status =
result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500
return NextResponse.json({ error: result.error }, { status })
return NextResponse.json(
{ error: result.error },
{ status: folderMutationStatus(result.errorCode) }
)
}
captureServerEvent(
session.user.id,
'folder_deleted',
{ workspace_id: existingFolder.workspaceId },
{ workspace_id: existingFolder.workspaceId, resource_type: resourceType },
{ groups: { workspace: existingFolder.workspaceId } }
)
@@ -184,6 +201,11 @@ export const DELETE = withRouteHandler(
return NextResponse.json({ error: error.message }, { status: error.status })
}
// A typed domain error carries its own status — `deleteTable` can still raise a 423
// `TableLockedError` if a lock is set between the subtree guard and the archive.
// Rethrow so `withRouteHandler` maps it instead of flattening it to a 500.
if (error instanceof HttpError) throw error
logger.error('Error deleting folder:', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
@@ -66,6 +66,33 @@ describe('PUT /api/folders/reorder', () => {
expect(data).toMatchObject({ success: true, updated: 1 })
})
it('maps a sibling-name collision from a reparent to a 409', async () => {
mockWhere
.mockReturnValueOnce([{ id: 'folder-1', workspaceId: 'workspace-123' }])
.mockReturnValueOnce([{ id: 'parent-1', workspaceId: 'workspace-123', archivedAt: null }])
.mockReturnValueOnce([
{ id: 'folder-1', parentId: null },
{ id: 'parent-1', parentId: null },
])
const uniqueViolation = Object.assign(new Error('duplicate key value'), { code: '23505' })
mockDb.transaction.mockImplementationOnce(async () => {
throw uniqueViolation
})
const req = createMockRequest('PUT', {
workspaceId: 'workspace-123',
resourceType: 'knowledge_base',
updates: [{ id: 'folder-1', sortOrder: 0, parentId: 'parent-1' }],
})
const response = await PUT(req)
expect(response.status).toBe(409)
const data = await response.json()
expect(data.error).toBe('A folder with this name already exists in this location')
})
it('rejects a parentId that belongs to another workspace', async () => {
mockWhere
.mockReturnValueOnce([{ id: 'folder-1', workspaceId: 'workspace-123' }])
+27 -11
View File
@@ -2,6 +2,7 @@ import { db } from '@sim/db'
import { folder as folderTable } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow'
import { getPostgresErrorCode } from '@sim/utils/errors'
import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { reorderFoldersContract } from '@/lib/api/contracts'
@@ -9,6 +10,7 @@ import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { folderResourceConfig } from '@/lib/folders/config'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('FolderReorderAPI')
@@ -25,7 +27,7 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
try {
const parsed = await parseRequest(reorderFoldersContract, req, {})
if (!parsed.success) return parsed.response
const { workspaceId, updates } = parsed.data.body
const { workspaceId, resourceType, updates } = parsed.data.body
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (!permission || permission === 'read') {
@@ -39,7 +41,7 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
const existingFolders = await db
.select({ id: folderTable.id, workspaceId: folderTable.workspaceId })
.from(folderTable)
.where(and(inArray(folderTable.id, folderIds), eq(folderTable.resourceType, 'workflow')))
.where(and(inArray(folderTable.id, folderIds), eq(folderTable.resourceType, resourceType)))
const validIds = new Set(
existingFolders.filter((f) => f.workspaceId === workspaceId).map((f) => f.id)
@@ -64,7 +66,7 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
})
.from(folderTable)
.where(
and(inArray(folderTable.id, targetParentIds), eq(folderTable.resourceType, 'workflow'))
and(inArray(folderTable.id, targetParentIds), eq(folderTable.resourceType, resourceType))
)
const validParentIds = new Set(
@@ -86,7 +88,7 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
.select({ id: folderTable.id, parentId: folderTable.parentId })
.from(folderTable)
.where(
and(eq(folderTable.workspaceId, workspaceId), eq(folderTable.resourceType, 'workflow'))
and(eq(folderTable.workspaceId, workspaceId), eq(folderTable.resourceType, resourceType))
)
const parentById = new Map<string, string | null>()
@@ -114,16 +116,19 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
}
}
for (const update of validUpdates) {
await assertFolderMutable(update.id)
if (update.parentId !== undefined) {
await assertFolderMutable(update.parentId)
// Folder locking is a workflow-only feature; other resource types leave `locked` false.
if (folderResourceConfig(resourceType).supportsLocking) {
for (const update of validUpdates) {
await assertFolderMutable(update.id)
if (update.parentId !== undefined) {
await assertFolderMutable(update.parentId)
}
}
}
await db.transaction(async (tx) => {
for (const update of validUpdates) {
const updateData: Record<string, unknown> = {
const updateData: Partial<typeof folderTable.$inferInsert> = {
sortOrder: update.sortOrder,
updatedAt: new Date(),
}
@@ -133,12 +138,12 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
await tx
.update(folderTable)
.set(updateData)
.where(and(eq(folderTable.id, update.id), eq(folderTable.resourceType, 'workflow')))
.where(and(eq(folderTable.id, update.id), eq(folderTable.resourceType, resourceType)))
}
})
logger.info(
`[${requestId}] Reordered ${validUpdates.length} folders in workspace ${workspaceId}`
`[${requestId}] Reordered ${validUpdates.length} ${resourceType} folders in workspace ${workspaceId}`
)
return NextResponse.json({ success: true, updated: validUpdates.length })
@@ -147,6 +152,17 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
return NextResponse.json({ error: error.message }, { status: error.status })
}
// Reorder can reparent, which moves a folder into a new sibling set and brings it under
// the partial unique index on active (workspaceId, resourceType, parent, name). The user
// picked both the name and the destination here, so this is a conflict to surface, not a
// name to silently deduplicate.
if (getPostgresErrorCode(error) === '23505') {
return NextResponse.json(
{ error: 'A folder with this name already exists in this location' },
{ status: 409 }
)
}
logger.error(`[${requestId}] Error reordering folders`, error)
return NextResponse.json({ error: 'Failed to reorder folders' }, { status: 500 })
}
+2 -1
View File
@@ -545,8 +545,9 @@ describe('Folders API Route', () => {
const data = await response.json()
expect(data).toHaveProperty('error', 'Internal server error')
expect(mockLogger.error).toHaveBeenCalledWith('Failed to create workflow folder', {
expect(mockLogger.error).toHaveBeenCalledWith('Failed to create folder', {
error: expect.any(Error),
resourceType: 'workflow',
})
})
+18 -12
View File
@@ -5,20 +5,15 @@ import { createFolderContract, listFoldersContract } from '@/lib/api/contracts'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { folderResourceConfig } from '@/lib/folders/config'
import { createFolder } from '@/lib/folders/lifecycle'
import { listFoldersForWorkspace, toFolderApi } from '@/lib/folders/queries'
import { folderMutationStatus } from '@/lib/folders/status'
import { captureServerEvent } from '@/lib/posthog/server'
import { performCreateFolder } from '@/lib/workflows/orchestration'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('FoldersAPI')
function folderMutationStatus(errorCode: string | undefined): number {
if (errorCode === 'validation') return 400
if (errorCode === 'conflict') return 409
if (errorCode === 'not_found') return 404
return 500
}
// GET - Fetch folders for a workspace
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
@@ -64,6 +59,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const {
id: clientId,
name,
resourceType,
workspaceId,
parentId,
sortOrder: providedSortOrder,
@@ -82,10 +78,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
)
}
await assertFolderMutable(parentId ?? null)
// Folder locking is a workflow-only feature; other resource types leave `locked` false.
if (folderResourceConfig(resourceType).supportsLocking) {
await assertFolderMutable(parentId ?? null)
}
const result = await performCreateFolder({
const result = await createFolder({
id: clientId,
resourceType,
userId: session.user.id,
workspaceId,
name,
@@ -102,12 +102,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const newFolder = result.folder
logger.info('Created new folder:', { id: newFolder.id, name, workspaceId, parentId })
logger.info('Created new folder', {
id: newFolder.id,
name,
resourceType,
workspaceId,
parentId,
})
captureServerEvent(
session.user.id,
'folder_created',
{ workspace_id: workspaceId },
{ workspace_id: workspaceId, resource_type: resourceType },
{ groups: { workspace: workspaceId } }
)
@@ -28,10 +28,10 @@ export async function prefetchHomeLists(
): Promise<void> {
await Promise.all([
queryClient.prefetchQuery({
queryKey: folderKeys.list(workspaceId, 'active'),
queryKey: folderKeys.list(workspaceId, 'active', 'workflow'),
queryFn: async () => {
const { folders } = await prefetchInternalJson<{ folders?: FolderApi[] }>(
`/api/folders?workspaceId=${workspaceId}&scope=active`
`/api/folders?workspaceId=${workspaceId}&scope=active&resourceType=workflow`
)
return (folders ?? []).map(mapFolder)
},
@@ -126,7 +126,7 @@ describe('workspace list prefetches', () => {
await prefetchHomeLists(client, WORKSPACE_ID)
expect(mockPrefetchInternalJson).toHaveBeenCalledWith(
`/api/folders?workspaceId=${WORKSPACE_ID}&scope=active`
`/api/folders?workspaceId=${WORKSPACE_ID}&scope=active&resourceType=workflow`
)
const cachedFolders = client.getQueryData(folderKeys.list(WORKSPACE_ID, 'active')) as Array<{
id: string
@@ -83,9 +83,9 @@ export async function prefetchWorkspaceSidebar(
staleTime: MOTHERSHIP_CHAT_LIST_STALE_TIME,
}),
queryClient.prefetchQuery({
queryKey: folderKeys.list(workspaceId, 'active'),
queryKey: folderKeys.list(workspaceId, 'active', 'workflow'),
queryFn: async () => {
const rows = await listFoldersForWorkspace(workspaceId, 'active')
const rows = await listFoldersForWorkspace(workspaceId, 'active', 'workflow')
return rows.map(mapFolder)
},
staleTime: FOLDER_LIST_STALE_TIME,
+7 -4
View File
@@ -416,10 +416,13 @@ const CLEANUP_TARGETS = [
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'),
/**
* `folder` is shared by all four resource types, but file folders are still written to
* `workspace_file_folders` and this pass must not hard-delete rows it does not own. One
* widened predicate rather than a target per type: same table, same soft-delete column,
* same workspace scoping — splitting it would only multiply the batched scans.
*/
additionalPredicate: inArray(folderTable.resourceType, ['workflow', 'knowledge_base', 'table']),
name: 'folder',
},
{
+105 -38
View File
@@ -10,6 +10,7 @@ import {
listFoldersContract,
reorderFoldersContract,
restoreFolderContract,
type ServedFolderResourceType,
updateFolderContract,
} from '@/lib/api/contracts'
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
@@ -52,10 +53,11 @@ export function mapFolder(folder: FolderApi): WorkflowFolder {
async function fetchFolders(
workspaceId: string,
scope: FolderQueryScope = 'active',
resourceType: ServedFolderResourceType = 'workflow',
signal?: AbortSignal
): Promise<WorkflowFolder[]> {
const { folders } = await requestJson(listFoldersContract, {
query: { workspaceId, scope },
query: { workspaceId, scope, resourceType },
signal,
})
return folders.map(mapFolder)
@@ -63,12 +65,17 @@ async function fetchFolders(
export function useFolders(
workspaceId?: string,
options?: { scope?: FolderQueryScope; enabled?: boolean }
options?: {
scope?: FolderQueryScope
enabled?: boolean
resourceType?: ServedFolderResourceType
}
) {
const scope = options?.scope ?? 'active'
const resourceType = options?.resourceType ?? 'workflow'
return useQuery({
queryKey: folderKeys.list(workspaceId, scope),
queryFn: ({ signal }) => fetchFolders(workspaceId as string, scope, signal),
queryKey: folderKeys.list(workspaceId, scope, resourceType),
queryFn: ({ signal }) => fetchFolders(workspaceId as string, scope, resourceType, signal),
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
placeholderData: keepPreviousData,
staleTime: FOLDER_LIST_STALE_TIME,
@@ -78,10 +85,13 @@ export function useFolders(
const selectFolderMap = (folders: WorkflowFolder[]): Record<string, WorkflowFolder> =>
Object.fromEntries(folders.map((folder) => [folder.id, folder]))
export function useFolderMap(workspaceId?: string) {
export function useFolderMap(
workspaceId?: string,
resourceType: ServedFolderResourceType = 'workflow'
) {
return useQuery({
queryKey: folderKeys.list(workspaceId),
queryFn: ({ signal }) => fetchFolders(workspaceId as string, 'active', signal),
queryKey: folderKeys.list(workspaceId, 'active', resourceType),
queryFn: ({ signal }) => fetchFolders(workspaceId as string, 'active', resourceType, signal),
enabled: Boolean(workspaceId),
placeholderData: keepPreviousData,
staleTime: FOLDER_LIST_STALE_TIME,
@@ -91,6 +101,7 @@ export function useFolderMap(workspaceId?: string) {
interface CreateFolderVariables {
workspaceId: string
resourceType?: ServedFolderResourceType
name: string
parentId?: string
sortOrder?: number
@@ -99,12 +110,14 @@ interface CreateFolderVariables {
interface UpdateFolderVariables {
workspaceId: string
resourceType?: ServedFolderResourceType
id: string
updates: Partial<Pick<WorkflowFolder, 'name' | 'parentId' | 'sortOrder' | 'locked'>>
}
interface DeleteFolderVariables {
workspaceId: string
resourceType?: ServedFolderResourceType
id: string
}
@@ -119,7 +132,9 @@ interface DuplicateFolderVariables {
/**
* Creates optimistic mutation handlers for folder operations
*/
function createFolderMutationHandlers<TVariables extends { workspaceId: string }>(
function createFolderMutationHandlers<
TVariables extends { workspaceId: string; resourceType?: ServedFolderResourceType },
>(
queryClient: ReturnType<typeof useQueryClient>,
name: string,
createOptimisticFolder: (
@@ -131,26 +146,36 @@ function createFolderMutationHandlers<TVariables extends { workspaceId: string }
) {
return createOptimisticMutationHandlers<WorkflowFolder, TVariables, WorkflowFolder>(queryClient, {
name,
getQueryKey: (variables) => folderKeys.list(variables.workspaceId),
getSnapshot: (variables) => ({ ...getFolderMap(variables.workspaceId) }),
getQueryKey: (variables) =>
folderKeys.list(variables.workspaceId, 'active', variables.resourceType ?? 'workflow'),
getSnapshot: (variables) => ({
...getFolderMap(variables.workspaceId, variables.resourceType ?? 'workflow'),
}),
generateTempId: customGenerateTempId ?? (() => generateTempId('temp-folder')),
createOptimisticItem: (variables, tempId) => {
const previousFolders = getFolderMap(variables.workspaceId)
const previousFolders = getFolderMap(
variables.workspaceId,
variables.resourceType ?? 'workflow'
)
return createOptimisticFolder(variables, tempId, previousFolders)
},
applyOptimisticUpdate: (tempId, item) => {
queryClient.setQueryData<WorkflowFolder[]>(folderKeys.list(item.workspaceId), (old) => [
...(old ?? []),
item,
])
queryClient.setQueryData<WorkflowFolder[]>(
folderKeys.list(item.workspaceId, 'active', item.resourceType),
(old) => [...(old ?? []), item]
)
},
replaceOptimisticEntry: (tempId, data) => {
queryClient.setQueryData<WorkflowFolder[]>(folderKeys.list(data.workspaceId), (old) =>
(old ?? []).map((folder) => (folder.id === tempId ? data : folder))
queryClient.setQueryData<WorkflowFolder[]>(
folderKeys.list(data.workspaceId, 'active', data.resourceType),
(old) => (old ?? []).map((folder) => (folder.id === tempId ? data : folder))
)
},
rollback: (snapshot, variables) => {
queryClient.setQueryData(folderKeys.list(variables.workspaceId), Object.values(snapshot))
queryClient.setQueryData(
folderKeys.list(variables.workspaceId, 'active', variables.resourceType ?? 'workflow'),
Object.values(snapshot)
)
},
})
}
@@ -172,7 +197,7 @@ export function useCreateFolder() {
userId: '',
workspaceId: variables.workspaceId,
parentId: variables.parentId || null,
resourceType: 'workflow' as const,
resourceType: variables.resourceType ?? 'workflow',
locked: false,
sortOrder:
variables.sortOrder ??
@@ -191,9 +216,14 @@ export function useCreateFolder() {
)
return useMutation({
mutationFn: async ({ workspaceId, sortOrder, ...payload }: CreateFolderVariables) => {
mutationFn: async ({
workspaceId,
sortOrder,
resourceType = 'workflow',
...payload
}: CreateFolderVariables) => {
const { folder } = await requestJson(createFolderContract, {
body: { ...payload, workspaceId, sortOrder },
body: { ...payload, workspaceId, sortOrder, resourceType },
})
return mapFolder(folder)
},
@@ -205,15 +235,23 @@ export function useUpdateFolder() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ workspaceId, id, updates }: UpdateFolderVariables) => {
mutationFn: async ({
workspaceId: _workspaceId,
resourceType = 'workflow',
id,
updates,
}: UpdateFolderVariables) => {
const { folder } = await requestJson(updateFolderContract, {
params: { id },
query: { resourceType },
body: updates,
})
return mapFolder(folder)
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) })
queryClient.invalidateQueries({
queryKey: folderKeys.resource(variables.resourceType ?? 'workflow'),
})
},
})
}
@@ -222,11 +260,17 @@ export function useDeleteFolderMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ workspaceId: _workspaceId, id }: DeleteFolderVariables) => {
return requestJson(deleteFolderContract, { params: { id } })
mutationFn: async ({
workspaceId: _workspaceId,
resourceType = 'workflow',
id,
}: DeleteFolderVariables) => {
return requestJson(deleteFolderContract, { params: { id }, query: { resourceType } })
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: folderKeys.lists() })
const resourceType = variables.resourceType ?? 'workflow'
queryClient.invalidateQueries({ queryKey: folderKeys.resource(resourceType) })
if (resourceType !== 'workflow') return
return invalidateWorkflowLists(queryClient, variables.workspaceId, ['active', 'archived'])
},
})
@@ -234,6 +278,7 @@ export function useDeleteFolderMutation() {
interface RestoreFolderVariables {
workspaceId: string
resourceType?: ServedFolderResourceType
folderId: string
}
@@ -241,14 +286,20 @@ export function useRestoreFolder() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ workspaceId, folderId }: RestoreFolderVariables) => {
mutationFn: async ({
workspaceId,
resourceType = 'workflow',
folderId,
}: RestoreFolderVariables) => {
return requestJson(restoreFolderContract, {
params: { id: folderId },
body: { workspaceId },
body: { workspaceId, resourceType },
})
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: folderKeys.lists() })
const resourceType = variables.resourceType ?? 'workflow'
queryClient.invalidateQueries({ queryKey: folderKeys.resource(resourceType) })
if (resourceType !== 'workflow') return
return invalidateWorkflowLists(queryClient, variables.workspaceId, ['active', 'archived'])
},
})
@@ -318,6 +369,7 @@ export function useDuplicateFolderMutation() {
interface ReorderFoldersVariables {
workspaceId: string
resourceType?: ServedFolderResourceType
updates: Array<{
id: string
sortOrder: number
@@ -329,18 +381,24 @@ export function useReorderFolders() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (variables: ReorderFoldersVariables): Promise<void> => {
await requestJson(reorderFoldersContract, { body: variables })
mutationFn: async ({
resourceType = 'workflow',
...variables
}: ReorderFoldersVariables): Promise<void> => {
await requestJson(reorderFoldersContract, { body: { ...variables, resourceType } })
},
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: folderKeys.list(variables.workspaceId) })
const snapshot = queryClient.getQueryData<WorkflowFolder[]>(
folderKeys.list(variables.workspaceId)
const listKey = folderKeys.list(
variables.workspaceId,
'active',
variables.resourceType ?? 'workflow'
)
await queryClient.cancelQueries({ queryKey: listKey })
const snapshot = queryClient.getQueryData<WorkflowFolder[]>(listKey)
const updatesById = new Map(variables.updates.map((update) => [update.id, update]))
queryClient.setQueryData<WorkflowFolder[]>(folderKeys.list(variables.workspaceId), (old) => {
queryClient.setQueryData<WorkflowFolder[]>(listKey, (old) => {
if (!old?.length) return old
return old.map((folder) => {
const update = updatesById.get(folder.id)
@@ -357,11 +415,20 @@ export function useReorderFolders() {
},
onError: (_error, variables, context) => {
if (context?.snapshot) {
queryClient.setQueryData(folderKeys.list(variables.workspaceId), context.snapshot)
queryClient.setQueryData(
folderKeys.list(variables.workspaceId, 'active', variables.resourceType ?? 'workflow'),
context.snapshot
)
}
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) })
queryClient.invalidateQueries({
queryKey: folderKeys.list(
variables.workspaceId,
'active',
variables.resourceType ?? 'workflow'
),
})
},
})
}
+15 -4
View File
@@ -1,15 +1,26 @@
import type { FolderResourceType } from '@/lib/api/contracts/folders'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
import type { WorkflowFolder } from '@/stores/folders/types'
const EMPTY_FOLDERS: WorkflowFolder[] = []
export function getFolders(workspaceId: string): WorkflowFolder[] {
export function getFolders(
workspaceId: string,
resourceType: FolderResourceType = 'workflow'
): WorkflowFolder[] {
return (
getQueryClient().getQueryData<WorkflowFolder[]>(folderKeys.list(workspaceId)) ?? EMPTY_FOLDERS
getQueryClient().getQueryData<WorkflowFolder[]>(
folderKeys.list(workspaceId, 'active', resourceType)
) ?? EMPTY_FOLDERS
)
}
export function getFolderMap(workspaceId: string): Record<string, WorkflowFolder> {
return Object.fromEntries(getFolders(workspaceId).map((folder) => [folder.id, folder]))
export function getFolderMap(
workspaceId: string,
resourceType: FolderResourceType = 'workflow'
): Record<string, WorkflowFolder> {
return Object.fromEntries(
getFolders(workspaceId, resourceType).map((folder) => [folder.id, folder])
)
}
+17 -2
View File
@@ -1,8 +1,23 @@
import type { FolderResourceType } from '@/lib/api/contracts/folders'
export type FolderQueryScope = 'active' | 'archived'
/**
* `resourceType` is part of the key, not an implicit default, because one workspace holds
* an independent folder tree per resource. Without it the Knowledge, Tables, and Workflows
* folder lists would share a single cache entry and overwrite each other.
*
* Typed against the full `FolderResourceType` rather than the narrower set the API serves,
* so a cached row's own `resourceType` can be used to address its list without a cast.
*/
export const folderKeys = {
all: ['folders'] as const,
lists: () => [...folderKeys.all, 'list'] as const,
list: (workspaceId: string | undefined, scope: FolderQueryScope = 'active') =>
[...folderKeys.lists(), workspaceId ?? '', scope] as const,
resource: (resourceType: FolderResourceType = 'workflow') =>
[...folderKeys.lists(), resourceType] as const,
list: (
workspaceId: string | undefined,
scope: FolderQueryScope = 'active',
resourceType: FolderResourceType = 'workflow'
) => [...folderKeys.resource(resourceType), workspaceId ?? '', scope] as const,
}
+27 -13
View File
@@ -5,6 +5,18 @@ import { defineRouteContract } from '@/lib/api/contracts/types'
export const folderResourceTypeSchema = z.enum(['workflow', 'file', 'knowledge_base', 'table'])
export type FolderResourceType = z.output<typeof folderResourceTypeSchema>
/**
* The resource types the generic folder engine actually serves. `file` is excluded on
* purpose: file folders still write the legacy `workspace_file_folders` table, so accepting
* the value here would answer "you have no folders" against a surface that plainly does.
* It gains an entry when the file cutover lands.
*/
export const servedFolderResourceTypeSchema = z
.enum(['workflow', 'knowledge_base', 'table'])
.default('workflow')
export type ServedFolderResourceType = z.output<typeof servedFolderResourceTypeSchema>
export const folderScopeSchema = z.enum(['active', 'archived'])
export const folderSchema = z.object({
@@ -29,23 +41,22 @@ export type FolderApi = z.output<typeof folderSchema>
export const listFoldersQuerySchema = z.object({
workspaceId: z.string({ error: 'Workspace ID is required' }).min(1, 'Workspace ID is required'),
/**
* 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'),
resourceType: servedFolderResourceTypeSchema,
scope: folderScopeSchema.default('active'),
})
/**
* Addresses which resource's folder tree an id-keyed route operates on. Required even
* though folder ids are UUIDs: without it, a caller holding a knowledge-base folder id
* could drive it through the workflow surface.
*/
export const folderResourceTypeQuerySchema = z.object({
resourceType: servedFolderResourceTypeSchema,
})
export const createFolderBodySchema = z.object({
id: z.string().uuid().optional(),
/**
* No `resourceType` here on purpose. The create path still writes workflow folders
* only, and accepting a value the route cannot honor would silently create the wrong
* kind of folder. It is added back with the generic `folder` table cutover.
*/
resourceType: servedFolderResourceTypeSchema,
name: z.string().trim().min(1, 'Name is required').max(255, 'Name is too long'),
workspaceId: z.string().min(1, 'Workspace ID is required'),
/** Mirrors `updateFolderBodySchema.parentId` so explicit `null` (root folder) is accepted on create. */
@@ -71,6 +82,7 @@ export const updateFolderBodySchema = z.object({
export const restoreFolderBodySchema = z.object({
workspaceId: z.string({ error: 'Workspace ID is required' }).min(1, 'Workspace ID is required'),
resourceType: servedFolderResourceTypeSchema,
})
export const duplicateFolderBodySchema = z.object({
@@ -82,7 +94,7 @@ export const duplicateFolderBodySchema = z.object({
export const reorderFoldersBodySchema = z.object({
workspaceId: z.string(),
/** See `createFolderBodySchema` — reorder still operates on workflow folders only. */
resourceType: servedFolderResourceTypeSchema,
updates: z
.array(
z.object({
@@ -134,6 +146,7 @@ export const updateFolderContract = defineRouteContract({
method: 'PUT',
path: '/api/folders/[id]',
params: folderIdParamsSchema,
query: folderResourceTypeQuerySchema,
body: updateFolderBodySchema,
response: {
mode: 'json',
@@ -147,6 +160,7 @@ export const deleteFolderContract = defineRouteContract({
method: 'DELETE',
path: '/api/folders/[id]',
params: folderIdParamsSchema,
query: folderResourceTypeQuerySchema,
response: {
mode: 'json',
schema: z.object({
+473
View File
@@ -0,0 +1,473 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
archiveFolderCascade,
collectArchivedSubtreeIds,
collectCascadeSubtreeIds,
type DbOrTx,
restoreFolderCascade,
restoreFolderRows,
toCascadeCounts,
} from '@/lib/folders/cascade'
import { FOLDER_RESOURCES, type FolderResourceConfig } from '@/lib/folders/config'
import { folderMutationStatus } from '@/lib/folders/status'
interface SelectCall {
where: unknown
}
interface UpdateCall {
table: unknown
set: Record<string, unknown>
where: unknown
}
/**
* Chainable stand-in for a drizzle handle. Select chains are awaited after `.where()`;
* update chains after `.returning()`. Results are dequeued in call order, so a test states
* exactly what each successive statement sees.
*/
function makeTx(options: { selects?: unknown[][]; updates?: unknown[][] } = {}) {
const selectQueue = [...(options.selects ?? [])]
const updateQueue = [...(options.updates ?? [])]
const selectCalls: SelectCall[] = []
const updateCalls: UpdateCall[] = []
const tx = {
select: () => ({
from: () => ({
where: (where: unknown) => {
selectCalls.push({ where })
return Promise.resolve(selectQueue.shift() ?? [])
},
}),
}),
update: (table: unknown) => ({
set: (set: Record<string, unknown>) => ({
where: (where: unknown) => {
const rows = updateQueue.shift() ?? []
const call: UpdateCall = { table, set, where }
updateCalls.push(call)
return {
returning: () => Promise.resolve(rows),
then: (resolve: (value: unknown) => unknown) => Promise.resolve(rows).then(resolve),
}
},
}),
}),
}
return { tx: tx as unknown as DbOrTx, selectCalls, updateCalls }
}
const CHILD_TABLE = { name: 'child_table' }
const DEPENDENT_TABLE = { name: 'dependent_table' }
function makeConfig(overrides: Partial<FolderResourceConfig> = {}): FolderResourceConfig {
return {
resourceType: 'table',
label: 'table',
countKey: 'tables',
table: CHILD_TABLE as never,
idColumn: 'child.id' as never,
folderIdColumn: 'child.folderId' as never,
workspaceColumn: 'child.workspaceId' as never,
deletedColumn: 'child.archivedAt' as never,
deletedKey: 'archivedAt',
buildSoftDeleteSet: (timestamp, now) => ({ archivedAt: timestamp, updatedAt: now }),
...overrides,
}
}
/** Flattens the nested `and(...)` objects the drizzle operator mocks produce. */
function flattenConditions(condition: unknown): Array<Record<string, unknown>> {
if (!condition || typeof condition !== 'object') return []
const node = condition as Record<string, unknown>
if (node.type === 'and' && Array.isArray(node.conditions)) {
return node.conditions.flatMap(flattenConditions)
}
return [node]
}
function hasCondition(
condition: unknown,
predicate: (node: Record<string, unknown>) => boolean
): boolean {
return flattenConditions(condition).some(predicate)
}
const TIMESTAMP = new Date('2026-01-01T00:00:00.000Z')
const NOW = new Date('2026-02-02T00:00:00.000Z')
describe('collectCascadeSubtreeIds', () => {
it('returns the folder plus every descendant in the cascade', async () => {
const { tx, selectCalls } = makeTx({
selects: [
[
{ id: 'root', parentId: null },
{ id: 'child', parentId: 'root' },
{ id: 'grandchild', parentId: 'child' },
{ id: 'unrelated', parentId: null },
],
],
})
const ids = await collectCascadeSubtreeIds(tx, 'ws-1', 'table', 'root', TIMESTAMP)
expect(ids).toEqual(['root', 'child', 'grandchild'])
expect(selectCalls).toHaveLength(1)
})
it('admits folders already stamped by this cascade so a retry reaches nested stragglers', async () => {
// The cascade stamps folders before children, so a failure during the child pass leaves
// intermediate folders archived. An active-only walk would drop `child` here and never
// reach the resources still live under it.
const { tx, selectCalls } = makeTx({
selects: [
[
{ id: 'root', parentId: null },
{ id: 'child', parentId: 'root' },
{ id: 'grandchild', parentId: 'child' },
],
],
})
const ids = await collectCascadeSubtreeIds(tx, 'ws-1', 'table', 'root', TIMESTAMP)
expect(ids).toEqual(['root', 'child', 'grandchild'])
// Either still active, or carrying this cascade's own stamp — never another snapshot's.
const clause = flattenConditions(selectCalls[0].where).find((node) => node.type === 'or')
expect(clause).toBeDefined()
const branches = (clause?.conditions ?? []) as Array<Record<string, unknown>>
expect(branches.some((node) => node.type === 'isNull')).toBe(true)
expect(branches.some((node) => node.right === TIMESTAMP)).toBe(true)
})
it('scopes the walk to the workspace and resourceType', async () => {
const { tx, selectCalls } = makeTx({ selects: [[]] })
await collectCascadeSubtreeIds(tx, 'ws-1', 'knowledge_base', 'root', TIMESTAMP)
expect(hasCondition(selectCalls[0].where, (node) => node.right === 'knowledge_base')).toBe(true)
expect(hasCondition(selectCalls[0].where, (node) => node.right === 'ws-1')).toBe(true)
})
})
describe('collectArchivedSubtreeIds', () => {
it('matches on the exact cascade timestamp so unrelated archived folders stay archived', async () => {
const { tx, selectCalls } = makeTx({
selects: [
[
{ id: 'root', parentId: null },
{ id: 'child', parentId: 'root' },
],
],
})
const ids = await collectArchivedSubtreeIds(tx, 'ws-1', 'table', 'root', TIMESTAMP)
expect(ids).toEqual(['root', 'child'])
expect(hasCondition(selectCalls[0].where, (node) => node.right === TIMESTAMP)).toBe(true)
})
it('terminates on a parent cycle instead of recursing forever', async () => {
const { tx } = makeTx({
selects: [
[
{ id: 'a', parentId: 'b' },
{ id: 'b', parentId: 'a' },
],
],
})
const ids = await collectArchivedSubtreeIds(tx, 'ws-1', 'table', 'a', TIMESTAMP)
expect(ids).toEqual(['a', 'b'])
})
})
describe('archiveFolderCascade', () => {
it('stamps folders before children, under one shared timestamp', async () => {
const { tx, updateCalls } = makeTx({
updates: [
[{ id: 'root' }, { id: 'sub' }],
[{ id: 'child-1' }, { id: 'child-2' }],
],
})
const counts = await archiveFolderCascade(tx, makeConfig(), 'ws-1', ['root', 'sub'], TIMESTAMP)
expect(counts).toEqual({ folders: 2, children: 2 })
expect(updateCalls).toHaveLength(2)
// Order is load-bearing: the folder must carry the stamp before any child does, so a
// failed cascade can be retried onto the same snapshot instead of minting a new one.
expect(updateCalls[0].table).not.toBe(CHILD_TABLE)
expect(updateCalls[0].set).toMatchObject({ deletedAt: TIMESTAMP })
expect(updateCalls[1].table).toBe(CHILD_TABLE)
expect(updateCalls[1].set).toEqual({ archivedAt: TIMESTAMP, updatedAt: TIMESTAMP })
})
it('stamps the folder before invoking an archiveChildren hook', async () => {
const seenAtHookTime: number[] = []
const { tx, updateCalls } = makeTx({ updates: [[{ id: 'root' }]] })
const archiveChildren = vi.fn().mockImplementation(async () => {
seenAtHookTime.push(updateCalls.length)
return 3
})
await archiveFolderCascade(tx, makeConfig({ archiveChildren }), 'ws-1', ['root'], TIMESTAMP)
// The hook walks resources one at a time and can fail partway, so the folder stamp must
// already be in place by then for the retry to reuse it.
expect(seenAtHookTime).toEqual([1])
})
it('skips rows that were already soft-deleted independently', async () => {
const { tx, updateCalls } = makeTx({ updates: [[], []] })
await archiveFolderCascade(tx, makeConfig(), 'ws-1', ['root'], TIMESTAMP)
for (const call of updateCalls) {
expect(hasCondition(call.where, (node) => node.type === 'isNull')).toBe(true)
}
})
it('stamps every row with the timestamp it is given, not one of its own', async () => {
const { tx, updateCalls } = makeTx({ updates: [[{ id: 'root' }], [{ id: 'child-1' }]] })
await archiveFolderCascade(tx, makeConfig(), 'ws-1', ['root'], TIMESTAMP)
// Regression guard: deleting an already-archived folder reuses that folder's existing
// deletedAt. A fresh stamp here would strand the children — the folder row keeps its
// original stamp, so restore would never match them.
expect(updateCalls[0].set).toMatchObject({ deletedAt: TIMESTAMP })
expect(updateCalls[1].set).toEqual({ archivedAt: TIMESTAMP, updatedAt: TIMESTAMP })
})
it('delegates to archiveChildren when a resource archives through its own lifecycle', async () => {
const archiveChildren = vi.fn().mockResolvedValue(7)
const { tx, updateCalls } = makeTx({ updates: [[{ id: 'root' }]] })
const counts = await archiveFolderCascade(
tx,
makeConfig({ archiveChildren }),
'ws-1',
['root', 'sub'],
TIMESTAMP
)
expect(archiveChildren).toHaveBeenCalledWith({
workspaceId: 'ws-1',
folderIds: ['root', 'sub'],
timestamp: TIMESTAMP,
})
expect(counts).toEqual({ folders: 1, children: 7 })
// Only the folder table is touched directly — the hook owns the child writes.
expect(updateCalls).toHaveLength(1)
expect(updateCalls[0].table).not.toBe(CHILD_TABLE)
})
})
describe('restoreFolderCascade', () => {
const dependents = [
{
table: DEPENDENT_TABLE as never,
childIdColumn: 'dependent.childId' as never,
deletedColumn: 'dependent.archivedAt' as never,
buildRestoreSet: (now: Date) => ({ archivedAt: null, updatedAt: now }),
},
]
it('restores folders, children, and dependents matching the cascade timestamp', async () => {
const { tx, updateCalls } = makeTx({
updates: [[{ id: 'root' }, { id: 'sub' }], [{ id: 'child-1' }, { id: 'child-2' }], []],
})
const counts = await restoreFolderCascade(
tx,
makeConfig({ restoreDependents: dependents }),
'ws-1',
['root', 'sub'],
TIMESTAMP,
NOW
)
expect(counts).toEqual({ folders: 2, children: 2 })
expect(updateCalls).toHaveLength(3)
expect(updateCalls[1].set).toEqual({ archivedAt: null, updatedAt: NOW })
expect(updateCalls[2].table).toBe(DEPENDENT_TABLE)
expect(
hasCondition(updateCalls[2].where, (node) => {
return node.type === 'inArray' && Array.isArray(node.values) && node.values.length === 2
})
).toBe(true)
})
it('issues a fixed number of statements regardless of subtree depth', async () => {
const deepSubtree = ['a', 'b', 'c', 'd', 'e', 'f']
const { tx, updateCalls } = makeTx({
updates: [deepSubtree.map((id) => ({ id })), [{ id: 'child-1' }], []],
})
await restoreFolderCascade(
tx,
makeConfig({ restoreDependents: dependents }),
'ws-1',
deepSubtree,
TIMESTAMP,
NOW
)
// One UPDATE for folders, one for children, one per dependent table — never per folder.
expect(updateCalls).toHaveLength(2 + dependents.length)
})
it('skips dependent writes when nothing was restored', async () => {
const { tx, updateCalls } = makeTx({ updates: [[{ id: 'root' }], []] })
const counts = await restoreFolderCascade(
tx,
makeConfig({ restoreDependents: dependents }),
'ws-1',
['root'],
TIMESTAMP,
NOW
)
expect(counts).toEqual({ folders: 1, children: 0 })
expect(updateCalls).toHaveLength(2)
})
it('restores only rows carrying the folders own soft-delete timestamp', async () => {
const { tx, updateCalls } = makeTx({ updates: [[{ id: 'root' }], [{ id: 'child-1' }], []] })
await restoreFolderCascade(
tx,
makeConfig({ restoreDependents: dependents }),
'ws-1',
['root'],
TIMESTAMP,
NOW
)
for (const call of updateCalls) {
expect(hasCondition(call.where, (node) => node.right === TIMESTAMP)).toBe(true)
}
})
})
describe('restoreFolderRows', () => {
it('restores only folders carrying the cascade timestamp', async () => {
const { tx, updateCalls } = makeTx({ updates: [[{ id: 'root' }, { id: 'sub' }]] })
const folders = await restoreFolderRows(
tx,
makeConfig(),
'ws-1',
['root', 'sub'],
TIMESTAMP,
NOW
)
expect(folders).toBe(2)
expect(updateCalls).toHaveLength(1)
expect(hasCondition(updateCalls[0].where, (node) => node.right === TIMESTAMP)).toBe(true)
})
})
describe('folderMutationStatus', () => {
it('maps a locked resource to 423, matching the single-resource delete', () => {
expect(folderMutationStatus('locked')).toBe(423)
})
it('maps the shared orchestration codes', () => {
expect(folderMutationStatus('validation')).toBe(400)
expect(folderMutationStatus('not_found')).toBe(404)
expect(folderMutationStatus('conflict')).toBe(409)
expect(folderMutationStatus('internal')).toBe(500)
expect(folderMutationStatus(undefined)).toBe(500)
})
})
describe('toCascadeCounts', () => {
it('reports the child count under the resources own key', () => {
expect(toCascadeCounts(makeConfig(), { folders: 2, children: 3 })).toEqual({
folders: 2,
tables: 3,
})
expect(
toCascadeCounts(makeConfig({ countKey: 'knowledgeBases' }), { folders: 1, children: 0 })
).toEqual({ folders: 1, knowledgeBases: 0 })
})
})
describe('FOLDER_RESOURCES', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('pairs every archiveChildren hook with a restoreChildren hook', () => {
// An archive hook exists because archiving touches more than the child row. Without the
// matching restore hook, a folder restore would revive the resource and strand whatever
// the archive hook took down with it.
for (const config of Object.values(FOLDER_RESOURCES)) {
if (!config.archiveChildren) continue
const hasRestorePath = Boolean(config.restoreChildren) || Boolean(config.restoreDependents)
expect(hasRestorePath).toBe(true)
}
})
it('grants lock semantics to workflows only', () => {
// `folder.locked` predates the generic table and is deliberately not extended. Anything
// that flips a second resource to lockable has to add the UI and authz to match.
const lockable = Object.values(FOLDER_RESOURCES)
.filter((config) => config.supportsLocking)
.map((config) => config.resourceType)
expect(lockable).toEqual(['workflow'])
})
it('guards the delete of resources that gate their own deletion', () => {
// Tables refuse deletion while delete-locked; deleting the folder around one must not
// become a way around that control.
expect(FOLDER_RESOURCES.table.guardDelete).toBeDefined()
})
it('declares one entry per folder resource type', () => {
expect(Object.keys(FOLDER_RESOURCES).sort()).toEqual([
'file',
'knowledge_base',
'table',
'workflow',
])
})
it('keys every entry consistently with its own resourceType', () => {
for (const [key, config] of Object.entries(FOLDER_RESOURCES)) {
expect(config.resourceType).toBe(key)
}
})
it('gives every resource a distinct cascade count key', () => {
const countKeys = Object.values(FOLDER_RESOURCES).map((config) => config.countKey)
expect(new Set(countKeys).size).toBe(countKeys.length)
})
it('builds soft-delete payloads that write the declared soft-delete property', () => {
for (const config of Object.values(FOLDER_RESOURCES)) {
const archiveSet = config.buildSoftDeleteSet(TIMESTAMP, NOW)
const restoreSet = config.buildSoftDeleteSet(null, NOW)
expect(archiveSet[config.deletedKey]).toBe(TIMESTAMP)
expect(restoreSet[config.deletedKey]).toBeNull()
}
})
it('restores dependents to an active state', () => {
for (const config of Object.values(FOLDER_RESOURCES)) {
for (const dependent of config.restoreDependents ?? []) {
expect(Object.values(dependent.buildRestoreSet(NOW))).toContain(null)
}
}
})
})
+239
View File
@@ -0,0 +1,239 @@
import type { db } from '@sim/db'
import { folder as folderTable } from '@sim/db/schema'
import { and, eq, inArray, isNull, or, type SQL } from 'drizzle-orm'
import type { FolderCascadeCountsApi, FolderResourceType } from '@/lib/api/contracts/folders'
import type { FolderResourceConfig } from '@/lib/folders/config'
import { collectDescendantFolderIds } from '@/lib/folders/subtree'
/** Narrow enough for both `db` and an open transaction handle. */
export type DbOrTx = Pick<typeof db, 'select' | 'update'>
export interface FolderCascadeCounts {
/** Folders in the cascade, including the root. */
folders: number
/** Resources of the folder's own type that moved with it. */
children: number
}
/**
* Resolves the folder plus every descendant that belongs to this delete cascade, in one
* query: folders still active, OR already stamped with this cascade's own `timestamp`.
*
* Both halves are load-bearing. Excluding other archived folders is what keeps a descendant
* archived independently — with its own timestamp — from being swept into this snapshot.
* Including folders stamped with *this* timestamp is what makes a retry work: the cascade
* stamps folders before children, so a failure partway through the child pass leaves nested
* subfolders already archived. An active-only walk would drop those intermediate folders and
* never reach the still-active resources beneath them, leaving them outside every future
* timestamp-matched restore.
*/
export async function collectCascadeSubtreeIds(
tx: DbOrTx,
workspaceId: string,
resourceType: FolderResourceType,
folderId: string,
timestamp: Date
): Promise<string[]> {
const cascadeFolders = await tx
.select({ id: folderTable.id, parentId: folderTable.parentId })
.from(folderTable)
.where(
and(
eq(folderTable.workspaceId, workspaceId),
eq(folderTable.resourceType, resourceType),
or(isNull(folderTable.deletedAt), eq(folderTable.deletedAt, timestamp))
)
)
return [folderId, ...collectDescendantFolderIds(cascadeFolders, folderId)]
}
/**
* Resolves the folder plus every descendant archived in the same cascade, in one query.
*
* Matching on the exact `timestamp` is what stops a restore from also reviving folders
* that were archived independently before or after — and is why this cannot reuse
* {@link collectActiveSubtreeIds}, which by definition cannot see an archived subtree.
*/
export async function collectArchivedSubtreeIds(
tx: DbOrTx,
workspaceId: string,
resourceType: FolderResourceType,
folderId: string,
timestamp: Date
): Promise<string[]> {
const archivedFolders = await tx
.select({ id: folderTable.id, parentId: folderTable.parentId })
.from(folderTable)
.where(
and(
eq(folderTable.workspaceId, workspaceId),
eq(folderTable.resourceType, resourceType),
eq(folderTable.deletedAt, timestamp)
)
)
return [folderId, ...collectDescendantFolderIds(archivedFolders, folderId)]
}
function childFilter(config: FolderResourceConfig, workspaceId: string, folderIds: string[]): SQL {
return and(
inArray(config.folderIdColumn, folderIds),
eq(config.workspaceColumn, workspaceId),
config.scope
) as SQL
}
/**
* 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: the restore path 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.
*
* Folders are stamped BEFORE their children, which is what makes a failed cascade
* recoverable. `archiveChildren` hooks walk resources one at a time through their canonical
* delete, so a mid-loop failure can leave some children archived and some not. With the
* folder already stamped, `deleteFolder` reuses that same `deletedAt` on the retry and the
* stragglers join the original snapshot. Stamping children first would leave the folder
* active, so a retry would mint a fresh timestamp and the partially-archived children could
* never be matched by any restore again.
*
* The cost is a window where the folder reads as deleted while a resource inside it is still
* active. That is the strictly better failure: it is transient and self-healing on retry,
* where the alternative silently and permanently strands data.
*/
export async function archiveFolderCascade(
tx: DbOrTx,
config: FolderResourceConfig,
workspaceId: string,
folderIds: string[],
timestamp: Date
): Promise<FolderCascadeCounts> {
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 children = config.archiveChildren
? await config.archiveChildren({ workspaceId, folderIds, timestamp })
: (
await tx
.update(config.table)
.set(config.buildSoftDeleteSet(timestamp, timestamp))
.where(and(childFilter(config, workspaceId, folderIds), isNull(config.deletedColumn)))
.returning({ id: config.idColumn })
).length
return { folders: archivedFolders.length, children }
}
/**
* Un-archives the folder rows of a restore cascade — the subtree resolved by
* {@link collectArchivedSubtreeIds}, matched on the exact `timestamp`.
*
* One statement regardless of subtree depth. Returns how many folders came back.
*/
export async function restoreFolderRows(
tx: DbOrTx,
config: FolderResourceConfig,
workspaceId: string,
folderIds: string[],
timestamp: Date,
now: Date
): Promise<number> {
const restoredFolders = await tx
.update(folderTable)
.set({ deletedAt: null, updatedAt: now })
.where(
and(
inArray(folderTable.id, folderIds),
eq(folderTable.workspaceId, workspaceId),
eq(folderTable.resourceType, config.resourceType),
eq(folderTable.deletedAt, timestamp)
)
)
.returning({ id: folderTable.id })
return restoredFolders.length
}
/**
* Un-archives the resources a restore cascade covers, plus the dependent rows (schedules,
* webhooks, chats) hanging off them — the default path, for resources whose restore is a
* plain row update.
*
* Fixed statement count regardless of subtree depth: one UPDATE for the resources and one
* per declared dependent table. Resources whose restore needs more than this declare
* {@link FolderResourceConfig.restoreChildren} instead and never reach here.
*/
export async function restoreFolderChildren(
tx: DbOrTx,
config: FolderResourceConfig,
workspaceId: string,
folderIds: string[],
timestamp: Date,
now: Date
): Promise<number> {
const restoredChildren = await tx
.update(config.table)
.set(config.buildSoftDeleteSet(null, now))
.where(and(childFilter(config, workspaceId, folderIds), eq(config.deletedColumn, timestamp)))
.returning({ id: config.idColumn })
const childIds = restoredChildren.map((row) => String(row.id))
if (childIds.length > 0) {
for (const dependent of config.restoreDependents ?? []) {
await tx
.update(dependent.table)
.set(dependent.buildRestoreSet(now))
.where(
and(inArray(dependent.childIdColumn, childIds), eq(dependent.deletedColumn, timestamp))
)
}
}
return childIds.length
}
/**
* Restores a folder subtree and the resources inside it, via the default row-update path.
*
* Only valid for resources without a {@link FolderResourceConfig.restoreChildren} hook —
* those hooks call canonical single-resource restores that open their own transactions and
* therefore must not run nested inside this one. `restoreFolder` sequences that case itself.
*/
export async function restoreFolderCascade(
tx: DbOrTx,
config: FolderResourceConfig,
workspaceId: string,
folderIds: string[],
timestamp: Date,
now: Date
): Promise<FolderCascadeCounts> {
const folders = await restoreFolderRows(tx, config, workspaceId, folderIds, timestamp, now)
const children = await restoreFolderChildren(tx, config, workspaceId, folderIds, timestamp, now)
return { folders, children }
}
/**
* 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(
config: FolderResourceConfig,
counts: FolderCascadeCounts
): FolderCascadeCountsApi {
return { folders: counts.folders, [config.countKey]: counts.children }
}
+478
View File
@@ -0,0 +1,478 @@
import {
chat,
knowledgeBase,
userTableDefinitions,
webhook,
workflow,
workflowMcpTool,
workflowSchedule,
workspaceFiles,
} from '@sim/db/schema'
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'
/**
* A table whose rows hang off a foldered resource and share its soft-delete lifecycle —
* e.g. a workflow's schedules and webhooks. Restored alongside the resource so a folder
* restore brings back a fully functional resource rather than a headless row.
*
* Archive is deliberately not expressed here: the archive direction has side effects
* beyond a row update (deactivating deployments, notifying the socket, external webhook
* teardown) and is owned by {@link FolderResourceConfig.archiveChildren}.
*/
export interface FolderDependentTable {
table: PgTable
/** Column on `table` holding the parent resource's id. */
childIdColumn: PgColumn
/** Soft-delete timestamp column, matched exactly against the cascade timestamp. */
deletedColumn: PgColumn
/** Typed at the definition site so a dropped or renamed column fails to compile. */
buildRestoreSet: (now: Date) => Record<string, unknown>
}
/** Everything the cascade needs to archive or restore the resources inside a folder. */
export interface CascadeChildrenContext {
workspaceId: string
/** The folder plus every descendant folder in the cascade, already resolved. */
folderIds: string[]
/** Shared across the whole cascade; restore matches on it exactly. */
timestamp: Date
}
/**
* Reason a delete must be refused. `locked` maps to 423, matching what the table domain
* returns when a mutation lock blocks the equivalent single-resource delete.
*/
export interface FolderDeleteRejection {
error: string
errorCode: 'validation' | 'conflict' | 'locked'
}
/**
* Everything that differs between the four folder-bearing resource types, expressed as
* data. The folder engine in `lib/folders/lifecycle.ts` and the cascade in
* `lib/folders/cascade.ts` read 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.
*/
export interface FolderResourceConfig {
resourceType: FolderResourceType
/** Human-readable noun used in audit-log descriptions and log lines. */
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. The `.set()` payloads themselves are built by
* {@link buildSoftDeleteSet} so they stay typed against the concrete table.
*/
deletedKey: 'deletedAt' | 'archivedAt'
/**
* Builds the `.set()` payload that soft-deletes (`timestamp`) or restores (`null`) a
* child row. Declared per resource with `satisfies Partial<typeof table.$inferInsert>`
* so a dropped or renamed column is a compile error rather than a silent no-op — never
* hand the cascade a `Record<string, unknown>` literal.
*/
buildSoftDeleteSet: (timestamp: Date | null, now: Date) => Record<string, unknown>
/**
* Whether folders of this type participate in the folder-locking feature.
*
* Only workflow folders do. `folder.locked` exists because workflow-folder locking shipped
* before the generic table; it is deliberately not extended to the other resource types.
* Declared here rather than checked as `resourceType === 'workflow'` at each call site, so
* every surface that touches locking asks the same question and a future lockable resource
* is one flag rather than a hunt through routes.
*/
supportsLocking?: boolean
/** Narrows which rows of `table` participate in folder membership at all. */
scope?: SQL
/**
* Column used to place a newly created folder above existing siblings. Only workflows
* order their resources alongside folders; knowledge bases and tables have no per-row
* sort order, so the new folder's position is derived from sibling folders alone.
*/
sortOrderColumn?: PgColumn
/** Rows restored alongside each resource; see {@link FolderDependentTable}. */
restoreDependents?: FolderDependentTable[]
/**
* Replaces the cascade's default "one UPDATE over the child table" when archiving a
* resource has side effects the row update cannot express — dependent graphs, lock
* checks, deployment teardown. Returns the number of resources archived.
*
* Where a canonical single-resource delete already exists, delegate to it rather than
* reimplementing its writes here: that is what keeps the folder cascade from drifting
* away from the resource's own delete path.
*/
archiveChildren?: (context: CascadeChildrenContext) => Promise<number>
/**
* Mirror of {@link archiveChildren} for restore. Required whenever `archiveChildren` is
* set and the archive touched more than the child row, otherwise a restore would revive
* the resource while leaving its dependents archived.
*
* Restoring a resource can also collide with its OWN active-name unique index (knowledge
* bases and tables both have one), which the canonical restore resolves by renaming —
* another reason to delegate rather than clear the tombstone directly.
*/
restoreChildren?: (context: CascadeChildrenContext) => Promise<number>
/**
* Runs before any write on delete. Returns a rejection to refuse the delete, or `null`
* to proceed. Use for invariants that must hold across the whole subtree, so the cascade
* either runs completely or not at all rather than failing partway through.
*/
guardDelete?: (context: {
workspaceId: string
folderIds: string[]
}) => Promise<FolderDeleteRejection | null>
}
/**
* Archives the workflows in a folder subtree through the workflow lifecycle rather than a
* bare UPDATE: archiving a workflow also deactivates its deployments, tears down external
* webhooks, notifies the realtime socket, and republishes MCP tool lists.
*
* Imported lazily so knowledge-base and table folder routes do not pull the workflow
* executor, socket client, and MCP pub/sub into their module graph.
*/
async function archiveWorkflowChildren({
workspaceId,
folderIds,
timestamp,
}: CascadeChildrenContext): Promise<number> {
const [{ db }, { and, eq: eqOp, inArray, isNull }, { archiveWorkflowsByIdsInWorkspace }] =
await Promise.all([
import('@sim/db'),
import('drizzle-orm'),
import('@/lib/workflows/lifecycle'),
])
const workflowsInFolders = await db
.select({ id: workflow.id })
.from(workflow)
.where(
and(
inArray(workflow.folderId, folderIds),
eqOp(workflow.workspaceId, workspaceId),
isNull(workflow.archivedAt)
)
)
if (workflowsInFolders.length === 0) return 0
// Report what the lifecycle actually archived, not what this select saw — a workflow
// archived concurrently between the two would otherwise be double-counted.
return archiveWorkflowsByIdsInWorkspace(
workspaceId,
workflowsInFolders.map((entry) => entry.id),
{ requestId: `folder-cascade-${folderIds[0]}`, archivedAt: timestamp }
)
}
/**
* Refuses to archive the last active workflow(s) in a workspace. A workspace with zero
* active workflows renders an unopenable editor, so the workflow surface has always
* blocked this; knowledge bases and tables have no such requirement.
*/
async function guardLastWorkflows({
workspaceId,
folderIds,
}: {
workspaceId: string
folderIds: string[]
}): Promise<FolderDeleteRejection | null> {
const [{ db }, { and, eq: eqOp, inArray, isNull }] = await Promise.all([
import('@sim/db'),
import('drizzle-orm'),
])
const [inFolders, inWorkspace] = await Promise.all([
db
.select({ id: workflow.id })
.from(workflow)
.where(
and(
inArray(workflow.folderId, folderIds),
eqOp(workflow.workspaceId, workspaceId),
isNull(workflow.archivedAt)
)
),
db
.select({ id: workflow.id })
.from(workflow)
.where(and(eqOp(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))),
])
if (inFolders.length > 0 && inFolders.length >= inWorkspace.length) {
return {
error: 'Cannot delete folder containing the only workflow(s) in the workspace',
errorCode: 'validation',
}
}
return null
}
/**
* Selects the ids of resources inside a folder subtree whose soft-delete column is in the
* requested state — active (`null`) when archiving, or stamped with the cascade timestamp
* when restoring.
*/
async function selectChildIds(
config: FolderResourceConfig,
{ workspaceId, folderIds, timestamp }: CascadeChildrenContext,
state: 'active' | 'archived'
): Promise<string[]> {
const [{ db }, { and, eq: eqOp, inArray, isNull }] = await Promise.all([
import('@sim/db'),
import('drizzle-orm'),
])
const rows = await db
.select({ id: config.idColumn })
.from(config.table)
.where(
and(
inArray(config.folderIdColumn, folderIds),
eqOp(config.workspaceColumn, workspaceId),
state === 'active' ? isNull(config.deletedColumn) : eqOp(config.deletedColumn, timestamp),
config.scope
)
)
return rows.map((row) => String(row.id))
}
/**
* Archives the knowledge bases in a folder subtree through the canonical KB delete, which
* also archives their documents and pauses their connectors. A bare `knowledge_base` row
* update would leave that graph live — connectors would keep syncing into a KB the UI shows
* as deleted.
*/
async function archiveKnowledgeBaseChildren(context: CascadeChildrenContext): Promise<number> {
const { deleteKnowledgeBase } = await import('@/lib/knowledge/service')
const ids = await selectChildIds(FOLDER_RESOURCES.knowledge_base, context, 'active')
for (const id of ids) {
await deleteKnowledgeBase(id, `folder-cascade-${context.folderIds[0]}`, {
archivedAt: context.timestamp,
})
}
return ids.length
}
/**
* Restores the knowledge bases this cascade archived, through the canonical KB restore so
* documents and connectors come back with them and the KB is renamed if its name was taken
* while it was gone.
*/
async function restoreKnowledgeBaseChildren(context: CascadeChildrenContext): Promise<number> {
const { restoreKnowledgeBase } = await import('@/lib/knowledge/service')
const ids = await selectChildIds(FOLDER_RESOURCES.knowledge_base, context, 'archived')
for (const id of ids) {
await restoreKnowledgeBase(id, `folder-cascade-${context.folderIds[0]}`)
}
return ids.length
}
/**
* Archives the tables in a folder subtree through the canonical table delete, so the
* `deleteLocked` guard in its WHERE clause still applies. {@link guardLockedTables} has
* already refused the whole folder if any table is locked, so this should not encounter one.
*/
async function archiveTableChildren(context: CascadeChildrenContext): Promise<number> {
const { deleteTable } = await import('@/lib/table/service')
const ids = await selectChildIds(FOLDER_RESOURCES.table, context, 'active')
for (const id of ids) {
await deleteTable(id, `folder-cascade-${context.folderIds[0]}`, undefined, {
archivedAt: context.timestamp,
})
}
return ids.length
}
/**
* Restores the tables this cascade archived, through the canonical table restore so a table
* whose name was taken while it was gone is renamed instead of tripping the active-name
* unique index.
*/
async function restoreTableChildren(context: CascadeChildrenContext): Promise<number> {
const { restoreTable } = await import('@/lib/table/service')
const ids = await selectChildIds(FOLDER_RESOURCES.table, context, 'archived')
for (const id of ids) {
await restoreTable(id, `folder-cascade-${context.folderIds[0]}`)
}
return ids.length
}
/**
* Refuses to delete a folder containing a delete-locked table.
*
* `deleteTable` gates archiving on `deleteLocked` because archiving destroys access to every
* row. Deleting the folder around it must not become a way to bypass that control. Checked
* across the whole subtree up front so the cascade never archives half the tables and then
* stops at a locked one.
*/
async function guardLockedTables({
workspaceId,
folderIds,
}: {
workspaceId: string
folderIds: string[]
}): Promise<FolderDeleteRejection | null> {
const [{ db }, { and, eq: eqOp, inArray, isNull }] = await Promise.all([
import('@sim/db'),
import('drizzle-orm'),
])
const locked = await db
.select({ name: userTableDefinitions.name })
.from(userTableDefinitions)
.where(
and(
inArray(userTableDefinitions.folderId, folderIds),
eqOp(userTableDefinitions.workspaceId, workspaceId),
isNull(userTableDefinitions.archivedAt),
eqOp(userTableDefinitions.deleteLocked, true)
)
)
if (locked.length === 0) return null
const names = locked.map((row) => row.name).join(', ')
return {
error: `Cannot delete folder: ${locked.length === 1 ? 'table' : 'tables'} ${names} ${locked.length === 1 ? 'is' : 'are'} delete-locked`,
errorCode: 'locked',
}
}
export const FOLDER_RESOURCES: Record<FolderResourceType, FolderResourceConfig> = {
workflow: {
resourceType: 'workflow',
label: 'workflow',
countKey: 'workflows',
table: workflow,
idColumn: workflow.id,
folderIdColumn: workflow.folderId,
workspaceColumn: workflow.workspaceId,
deletedColumn: workflow.archivedAt,
deletedKey: 'archivedAt',
buildSoftDeleteSet: (timestamp, now) =>
({ archivedAt: timestamp, updatedAt: now }) satisfies Partial<typeof workflow.$inferInsert>,
sortOrderColumn: workflow.sortOrder,
restoreDependents: [
{
table: workflowSchedule,
childIdColumn: workflowSchedule.workflowId,
deletedColumn: workflowSchedule.archivedAt,
buildRestoreSet: (now) =>
({ archivedAt: null, updatedAt: now }) satisfies Partial<
typeof workflowSchedule.$inferInsert
>,
},
{
table: webhook,
childIdColumn: webhook.workflowId,
deletedColumn: webhook.archivedAt,
buildRestoreSet: (now) =>
({ archivedAt: null, updatedAt: now }) satisfies Partial<typeof webhook.$inferInsert>,
},
{
table: chat,
childIdColumn: chat.workflowId,
deletedColumn: chat.archivedAt,
buildRestoreSet: (now) =>
({ archivedAt: null, updatedAt: now }) satisfies Partial<typeof chat.$inferInsert>,
},
{
table: workflowMcpTool,
childIdColumn: workflowMcpTool.workflowId,
deletedColumn: workflowMcpTool.archivedAt,
buildRestoreSet: (now) =>
({ archivedAt: null, updatedAt: now }) satisfies Partial<
typeof workflowMcpTool.$inferInsert
>,
},
],
supportsLocking: true,
archiveChildren: archiveWorkflowChildren,
guardDelete: guardLastWorkflows,
},
file: {
resourceType: 'file',
label: 'file',
countKey: 'files',
table: workspaceFiles,
idColumn: workspaceFiles.id,
folderIdColumn: workspaceFiles.folderId,
workspaceColumn: workspaceFiles.workspaceId,
deletedColumn: workspaceFiles.deletedAt,
deletedKey: 'deletedAt',
buildSoftDeleteSet: (timestamp) =>
({ deletedAt: timestamp }) satisfies Partial<typeof workspaceFiles.$inferInsert>,
/**
* `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',
buildSoftDeleteSet: (timestamp, now) =>
({ deletedAt: timestamp, updatedAt: now }) satisfies Partial<
typeof knowledgeBase.$inferInsert
>,
archiveChildren: archiveKnowledgeBaseChildren,
restoreChildren: restoreKnowledgeBaseChildren,
},
table: {
resourceType: 'table',
label: 'table',
countKey: 'tables',
table: userTableDefinitions,
idColumn: userTableDefinitions.id,
folderIdColumn: userTableDefinitions.folderId,
workspaceColumn: userTableDefinitions.workspaceId,
deletedColumn: userTableDefinitions.archivedAt,
deletedKey: 'archivedAt',
buildSoftDeleteSet: (timestamp, now) =>
({ archivedAt: timestamp, updatedAt: now }) satisfies Partial<
typeof userTableDefinitions.$inferInsert
>,
archiveChildren: archiveTableChildren,
restoreChildren: restoreTableChildren,
guardDelete: guardLockedTables,
},
}
export function folderResourceConfig(resourceType: FolderResourceType): FolderResourceConfig {
return FOLDER_RESOURCES[resourceType]
}
+524
View File
@@ -0,0 +1,524 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { folder as folderTable } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getPostgresErrorCode } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, eq, isNull, min } from 'drizzle-orm'
import type { FolderCascadeCountsApi, FolderResourceType } from '@/lib/api/contracts/folders'
import {
archiveFolderCascade,
collectArchivedSubtreeIds,
collectCascadeSubtreeIds,
restoreFolderChildren,
restoreFolderRows,
toCascadeCounts,
} from '@/lib/folders/cascade'
import { folderResourceConfig } from '@/lib/folders/config'
import { deduplicateFolderName } from '@/lib/folders/naming'
import { wouldCreateFolderCycle } from '@/lib/folders/queries'
import type { FolderMutationErrorCode } from '@/lib/folders/status'
import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types'
const logger = createLogger('FolderLifecycle')
const DUPLICATE_NAME_ERROR = 'A folder with this name already exists in this location'
export interface CreateFolderParams {
resourceType: FolderResourceType
userId: string
workspaceId: string
name: string
id?: string
parentId?: string | null
sortOrder?: number
}
export interface FolderMutationResult {
success: boolean
error?: string
errorCode?: OrchestrationErrorCode
folder?: typeof folderTable.$inferSelect
}
export interface UpdateFolderParams {
resourceType: FolderResourceType
folderId: string
workspaceId: string
userId: string
name?: string
locked?: boolean
parentId?: string | null
sortOrder?: number
}
export interface DeleteFolderParams {
resourceType: FolderResourceType
folderId: string
workspaceId: string
userId: string
folderName?: string
}
export interface DeleteFolderResult {
success: boolean
error?: string
errorCode?: FolderMutationErrorCode
deletedItems?: FolderCascadeCountsApi
}
export interface RestoreFolderParams {
resourceType: FolderResourceType
folderId: string
workspaceId: string
userId: string
folderName?: string
}
export interface RestoreFolderResult {
success: boolean
error?: string
errorCode?: OrchestrationErrorCode
restoredItems?: FolderCascadeCountsApi
}
/**
* Verifies that a prospective parent folder exists, belongs to the target workspace, is of
* the same `resourceType`, and is not archived.
*
* The resourceType check is not defensive padding: the DB trigger
* `folder_parent_resource_type_match` enforces the same invariant, so an app layer that
* skipped it would surface a raw trigger error instead of a 400.
*/
async function assertParentFolderInWorkspace(
resourceType: FolderResourceType,
parentId: string,
workspaceId: string
): Promise<{ error: string; errorCode: OrchestrationErrorCode } | null> {
const [parent] = await db
.select({
workspaceId: folderTable.workspaceId,
archivedAt: folderTable.deletedAt,
})
.from(folderTable)
.where(and(eq(folderTable.id, parentId), eq(folderTable.resourceType, resourceType)))
.limit(1)
if (!parent || parent.workspaceId !== workspaceId || parent.archivedAt) {
return { error: 'Parent folder not found', errorCode: 'validation' }
}
return null
}
/**
* Places a new folder above its existing siblings. Workflows share one ordering space with
* their folders, so their `sortOrder` participates; knowledge bases and tables have no
* per-row ordering and are ignored via the absent `sortOrderColumn`.
*/
async function nextFolderSortOrder(
resourceType: FolderResourceType,
workspaceId: string,
parentId: string | null | undefined
): Promise<number> {
const config = folderResourceConfig(resourceType)
const folderParentCondition = parentId
? eq(folderTable.parentId, parentId)
: isNull(folderTable.parentId)
const folderMinPromise = db
.select({ minSortOrder: min(folderTable.sortOrder) })
.from(folderTable)
.where(
and(
eq(folderTable.workspaceId, workspaceId),
eq(folderTable.resourceType, resourceType),
folderParentCondition
)
)
const childMinPromise: Promise<Array<{ minSortOrder: unknown }>> = config.sortOrderColumn
? db
.select({ minSortOrder: min(config.sortOrderColumn) })
.from(config.table)
.where(
and(
eq(config.workspaceColumn, workspaceId),
parentId ? eq(config.folderIdColumn, parentId) : isNull(config.folderIdColumn),
config.scope
)
)
: Promise.resolve([])
const [[folderResult], [childResult]] = await Promise.all([folderMinPromise, childMinPromise])
const candidates = [folderResult?.minSortOrder, childResult?.minSortOrder]
.filter((value) => value != null)
.map(Number)
.filter((value) => Number.isFinite(value))
return candidates.length > 0 ? Math.min(...candidates) - 1 : 0
}
export async function createFolder(params: CreateFolderParams): Promise<FolderMutationResult> {
const config = folderResourceConfig(params.resourceType)
try {
const folderId = params.id || generateId()
const parentId = params.parentId || null
if (parentId) {
if (parentId === folderId) {
return { success: false, error: 'Folder cannot be its own parent', errorCode: 'validation' }
}
const parentError = await assertParentFolderInWorkspace(
params.resourceType,
parentId,
params.workspaceId
)
if (parentError) return { success: false, ...parentError }
}
const sortOrder =
params.sortOrder !== undefined
? params.sortOrder
: await nextFolderSortOrder(params.resourceType, params.workspaceId, parentId)
const [folder] = await db
.insert(folderTable)
.values({
id: folderId,
resourceType: params.resourceType,
name: params.name.trim(),
userId: params.userId,
workspaceId: params.workspaceId,
parentId,
sortOrder,
})
.returning()
logger.info('Created folder', {
folderId,
resourceType: params.resourceType,
workspaceId: params.workspaceId,
parentId,
})
recordAudit({
workspaceId: params.workspaceId,
actorId: params.userId,
action: AuditAction.FOLDER_CREATED,
resourceType: AuditResourceType.FOLDER,
resourceId: folderId,
resourceName: folder.name,
description: `Created ${config.label} folder "${folder.name}"`,
metadata: {
name: folder.name,
workspaceId: params.workspaceId,
folderResourceType: params.resourceType,
parentId: parentId || undefined,
sortOrder: folder.sortOrder,
},
})
return { success: true, folder }
} catch (error) {
// The partial unique index on active (workspaceId, resourceType, parent, name) makes a
// duplicate sibling name rejectable here. Create is a path where the user chooses the
// name, so surface a 409 rather than silently deduplicating.
if (getPostgresErrorCode(error) === '23505') {
return { success: false, error: DUPLICATE_NAME_ERROR, errorCode: 'conflict' }
}
logger.error('Failed to create folder', { error, resourceType: params.resourceType })
return { success: false, error: 'Internal server error', errorCode: 'internal' }
}
}
export async function updateFolder(params: UpdateFolderParams): Promise<FolderMutationResult> {
const config = folderResourceConfig(params.resourceType)
try {
if (params.parentId && params.parentId === params.folderId) {
return { success: false, error: 'Folder cannot be its own parent', errorCode: 'validation' }
}
if (params.parentId) {
const parentError = await assertParentFolderInWorkspace(
params.resourceType,
params.parentId,
params.workspaceId
)
if (parentError) return { success: false, ...parentError }
const wouldCreateCycle = await wouldCreateFolderCycle(
params.folderId,
params.parentId,
params.resourceType
)
if (wouldCreateCycle) {
return {
success: false,
error: 'Cannot create circular folder reference',
errorCode: 'validation',
}
}
}
// Typed against the table rather than `Record<string, unknown>`: the loose type is what
// let `color`/`isExpanded` survive an earlier cutover after the create path dropped them.
const updates: Partial<typeof folderTable.$inferInsert> = { updatedAt: new Date() }
if (params.name !== undefined) updates.name = params.name.trim()
// Backstop for the route's rejection: the engine is also reachable from the copilot
// tools, and a `locked` value on a type that has no lock semantics must never persist.
if (params.locked !== undefined && config.supportsLocking) updates.locked = params.locked
if (params.parentId !== undefined) updates.parentId = params.parentId || null
if (params.sortOrder !== undefined) updates.sortOrder = params.sortOrder
const [folder] = await db
.update(folderTable)
.set(updates)
.where(
and(
eq(folderTable.id, params.folderId),
eq(folderTable.workspaceId, params.workspaceId),
eq(folderTable.resourceType, params.resourceType)
)
)
.returning()
if (!folder) {
return { success: false, error: 'Folder not found', errorCode: 'not_found' }
}
logger.info('Updated folder', {
folderId: params.folderId,
resourceType: params.resourceType,
updates,
})
return { success: true, folder }
} catch (error) {
if (getPostgresErrorCode(error) === '23505') {
return { success: false, error: DUPLICATE_NAME_ERROR, errorCode: 'conflict' }
}
logger.error('Failed to update folder', { error, resourceType: params.resourceType })
return { success: false, error: 'Internal server error', errorCode: 'internal' }
}
}
/**
* Soft-deletes a folder and everything under it. The subtree is resolved once, handed to
* the resource's optional delete guard, then archived under one shared timestamp so
* {@link restoreFolder} can bring back exactly this set and nothing else.
*
* Deleting an already-archived folder reuses that folder's existing `deletedAt` rather than
* stamping a fresh one. A new timestamp here would be unrecoverable: the folder row keeps
* its original stamp (the cascade only archives active folders), so anything archived under
* the new stamp would never match on restore and would be stranded permanently.
*/
export async function deleteFolder(params: DeleteFolderParams): Promise<DeleteFolderResult> {
const { resourceType, folderId, workspaceId, userId, folderName } = params
const config = folderResourceConfig(resourceType)
const [existing] = await db
.select({ deletedAt: folderTable.deletedAt })
.from(folderTable)
.where(
and(
eq(folderTable.id, folderId),
eq(folderTable.workspaceId, workspaceId),
eq(folderTable.resourceType, resourceType)
)
)
.limit(1)
if (!existing) {
return { success: false, error: 'Folder not found', errorCode: 'not_found' }
}
// Resolve the timestamp before the subtree, because the subtree walk needs it: on a retry
// it is what distinguishes folders this cascade already stamped from folders archived
// independently.
const timestamp = existing.deletedAt ?? new Date()
const folderIds = await collectCascadeSubtreeIds(
db,
workspaceId,
resourceType,
folderId,
timestamp
)
const rejection = await config.guardDelete?.({ workspaceId, folderIds })
if (rejection) {
return { success: false, error: rejection.error, errorCode: rejection.errorCode }
}
const counts = await archiveFolderCascade(db, config, workspaceId, folderIds, timestamp)
logger.info('Deleted folder and all contents', { folderId, resourceType, counts })
recordAudit({
workspaceId,
actorId: userId,
action: AuditAction.FOLDER_DELETED,
resourceType: AuditResourceType.FOLDER,
resourceId: folderId,
resourceName: folderName,
description: `Deleted ${config.label} folder "${folderName || folderId}"`,
metadata: {
folderResourceType: resourceType,
affected: {
[config.countKey]: counts.children,
subfolders: Math.max(counts.folders - 1, 0),
},
},
})
return { success: true, deletedItems: toCascadeCounts(config, counts) }
}
/**
* Restores an archived folder together with everything archived alongside it.
*
* Two name hazards are handled before the cascade runs, both inside the transaction:
* a folder whose parent is still archived is re-rooted, and the restored name is
* deduplicated against the *resolved* parent's active siblings — the caller cannot rename
* an archived folder, so a taken name would otherwise make it permanently unrestorable.
*/
export async function restoreFolder(params: RestoreFolderParams): Promise<RestoreFolderResult> {
const { resourceType, folderId, workspaceId, userId, folderName } = params
const config = folderResourceConfig(resourceType)
const [folder] = await db
.select()
.from(folderTable)
.where(
and(
eq(folderTable.id, folderId),
eq(folderTable.workspaceId, workspaceId),
eq(folderTable.resourceType, resourceType)
)
)
if (!folder) {
return { success: false, error: 'Folder not found', errorCode: 'not_found' }
}
const archivedAt = folder.deletedAt
if (!archivedAt) {
return { success: true, restoredItems: toCascadeCounts(config, { folders: 0, children: 0 }) }
}
const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils')
const ws = await getWorkspaceWithOwner(workspaceId)
if (!ws || ws.archivedAt) {
return {
success: false,
error: 'Cannot restore folder into an archived workspace',
errorCode: 'validation',
}
}
const folderIds = await collectArchivedSubtreeIds(
db,
workspaceId,
resourceType,
folderId,
archivedAt
)
// Resources with a `restoreChildren` hook come back BEFORE the folder rows. Those hooks
// call canonical restores that open their own transactions, so they cannot run inside the
// one below — and doing them first is what keeps a partial failure recoverable. Restoring
// folders first would clear the root's `deletedAt`, so a later child failure would
// short-circuit every retry on the `!archivedAt` early return above, stranding whatever
// had not come back yet. In this order a failure leaves the folder archived and the whole
// restore simply retryable; children already restored no longer match the timestamp and
// are skipped.
const hookChildren = config.restoreChildren
? await config.restoreChildren({ workspaceId, folderIds, timestamp: archivedAt })
: null
let counts: { folders: number; children: number }
try {
counts = await db.transaction(async (tx) => {
const now = new Date()
let resolvedParentId = folder.parentId
if (folder.parentId) {
const [parentFolder] = await tx
.select({ archivedAt: folderTable.deletedAt })
.from(folderTable)
.where(
and(eq(folderTable.id, folder.parentId), eq(folderTable.resourceType, resourceType))
)
if (!parentFolder || parentFolder.archivedAt) {
resolvedParentId = null
await tx
.update(folderTable)
.set({ parentId: null })
.where(and(eq(folderTable.id, folderId), eq(folderTable.resourceType, resourceType)))
}
}
// Safe to rename while the row is still archived: the unique index only covers active
// rows, so this cannot collide before the cascade 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,
resolvedParentId,
folder.name,
resourceType
)
if (restoredName !== folder.name) {
logger.info('Renamed folder on restore to avoid a sibling name conflict', {
folderId,
resourceType,
from: folder.name,
to: restoredName,
})
await tx.update(folderTable).set({ name: restoredName }).where(eq(folderTable.id, folderId))
}
const folders = await restoreFolderRows(tx, config, workspaceId, folderIds, archivedAt, now)
const children =
hookChildren ??
(await restoreFolderChildren(tx, config, workspaceId, folderIds, archivedAt, now))
return { folders, children }
})
} catch (error) {
// Clearing `deletedAt` brings the row back under the partial unique index on active
// (workspaceId, resourceType, parent, name). Dedup above covers the restore root, but a
// concurrent create can still take the name between the check and the write.
if (getPostgresErrorCode(error) === '23505') {
return { success: false, error: DUPLICATE_NAME_ERROR, errorCode: 'conflict' }
}
throw error
}
logger.info('Restored folder and all contents', { folderId, resourceType, counts })
recordAudit({
workspaceId,
actorId: userId,
action: AuditAction.FOLDER_RESTORED,
resourceType: AuditResourceType.FOLDER,
resourceId: folderId,
resourceName: folderName ?? folder.name,
description: `Restored ${config.label} folder "${folderName ?? folder.name}"`,
metadata: {
folderResourceType: resourceType,
affected: {
[config.countKey]: counts.children,
subfolders: Math.max(counts.folders - 1, 0),
},
},
})
return { success: true, restoredItems: toCascadeCounts(config, counts) }
}
+3 -2
View File
@@ -1,4 +1,5 @@
import { type db, folder as folderTable } from '@sim/db'
import type { db } from '@sim/db'
import { folder as folderTable } from '@sim/db/schema'
import { and, eq, isNull } from 'drizzle-orm'
import type { FolderResourceType } from '@/lib/api/contracts/folders'
@@ -23,7 +24,7 @@ export async function deduplicateFolderName(
workspaceId: string,
parentId: string | null,
requestedName: string,
resourceType: FolderResourceType = 'workflow'
resourceType: FolderResourceType
): Promise<string> {
const siblingRows = await tx
.select({ name: folderTable.name })
+30 -1
View File
@@ -20,11 +20,40 @@ export function toFolderApi(row: typeof folder.$inferSelect): FolderApi {
}
}
/**
* Walks up from `parentId` to check whether reparenting `folderId` under it would close a
* cycle. Scoped to `resourceType` so the walk cannot escape into another resource's tree
* via an id the caller supplied.
*/
export async function wouldCreateFolderCycle(
folderId: string,
parentId: string,
resourceType: FolderResourceType
): Promise<boolean> {
let currentParentId: string | null = parentId
const visited = new Set<string>()
while (currentParentId) {
if (visited.has(currentParentId) || currentParentId === folderId) return true
visited.add(currentParentId)
const [parent] = await db
.select({ parentId: folder.parentId })
.from(folder)
.where(and(eq(folder.id, currentParentId), eq(folder.resourceType, resourceType)))
.limit(1)
currentParentId = parent?.parentId || null
}
return false
}
/** Shared by `GET /api/folders` and the sidebar prefetch so the query never drifts between them. */
export async function listFoldersForWorkspace(
workspaceId: string,
scope: FolderQueryScope,
resourceType: FolderResourceType = 'workflow'
resourceType: FolderResourceType
): Promise<FolderApi[]> {
const scopeFilter = scope === 'archived' ? isNotNull(folder.deletedAt) : isNull(folder.deletedAt)
+25
View File
@@ -0,0 +1,25 @@
import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types'
/**
* Folder mutations can fail for one reason the shared orchestration vocabulary has no word
* for: a resource inside the folder carries a mutation lock. Kept as a folder-local widening
* rather than pushed into `OrchestrationErrorCode`, which is shared with deployment and
* workflow surfaces that have no such state.
*/
export type FolderMutationErrorCode = OrchestrationErrorCode | 'locked'
/**
* Maps a folder mutation error code to its HTTP status. Shared by every folder route so the
* surfaces cannot drift — `locked` in particular must reach the client as 423, matching what
* the table domain returns when the same lock blocks a single-table delete.
*
* Deliberately kept out of `lib/folders/lifecycle.ts`: this is a pure mapping with no
* database access, and routes need it to stay real in tests that mock the lifecycle module.
*/
export function folderMutationStatus(errorCode: FolderMutationErrorCode | undefined): number {
if (errorCode === 'validation') return 400
if (errorCode === 'not_found') return 404
if (errorCode === 'conflict') return 409
if (errorCode === 'locked') return 423
return 500
}
+8 -2
View File
@@ -665,12 +665,18 @@ export async function getKnowledgeBaseById(
/**
* Delete a knowledge base (soft delete)
*
* `options.archivedAt` lets a bulk caller stamp every row it archives with one shared
* timestamp, which is how the folder cascade later identifies exactly what it archived and
* restores that set and nothing else. Mirrors `archiveWorkflow`'s option of the same name.
* Defaults to now, so single-KB callers are unaffected.
*/
export async function deleteKnowledgeBase(
knowledgeBaseId: string,
requestId: string
requestId: string,
options?: { archivedAt?: Date }
): Promise<void> {
const now = new Date()
const now = options?.archivedAt ?? new Date()
await db.transaction(async (tx) => {
await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`)
+3
View File
@@ -632,10 +632,12 @@ export interface PostHogEventMap {
folder_created: {
workspace_id: string
resource_type?: string
}
folder_deleted: {
workspace_id: string
resource_type?: string
}
folder_renamed: {
@@ -657,6 +659,7 @@ export interface PostHogEventMap {
folder_restored: {
folder_id: string
workspace_id: string
resource_type?: string
}
logs_filter_applied: {
+7 -2
View File
@@ -747,13 +747,18 @@ export async function updateTableMetadata(
*
* @param tableId - Table ID to delete
* @param requestId - Request ID for logging
* @param actingUserId - User performing the delete, for audit
* @param options.archivedAt - Shared timestamp for a bulk archive (folder cascade), so the
* matching restore can identify exactly the set it archived. Defaults to now, leaving
* single-table callers unaffected. Mirrors `archiveWorkflow`'s option of the same name.
*/
export async function deleteTable(
tableId: string,
requestId: string,
actingUserId?: string
actingUserId?: string,
options?: { archivedAt?: Date }
): Promise<void> {
const now = new Date()
const now = options?.archivedAt ?? new Date()
// Archiving destroys access to every row, so it is gated on the delete lock.
// The guard is inline in the WHERE (atomic — no separate read, no TOCTOU);
// a zero-row result is then disambiguated below (locked vs already-archived).
@@ -1,23 +1,18 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import {
chat,
folder as folderTable,
webhook,
workflow,
workflowMcpTool,
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 { deduplicateFolderName } from '@/lib/folders/naming'
import { archiveWorkflowsByIdsInWorkspace } from '@/lib/workflows/lifecycle'
import type { folder as folderTable } from '@sim/db/schema'
import { createFolder, deleteFolder, restoreFolder, updateFolder } from '@/lib/folders/lifecycle'
import type { FolderMutationErrorCode } from '@/lib/folders/status'
import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types'
import { checkForCircularReference } from '@/lib/workflows/utils'
const logger = createLogger('FolderLifecycle')
/**
* Workflow-bound entry points into the generic folder engine in `lib/folders/lifecycle.ts`.
*
* The engine is resourceType-driven and owns the actual writes; these wrappers exist so the
* workflow callers that predate it (the folders API, the copilot workflow tools, the
* resource-restore orchestrator) keep a single, workflow-shaped signature. Everything that
* differs for workflows — the last-workflow delete guard, archiving through the workflow
* lifecycle so deployments and webhooks tear down, and restoring schedules/webhooks/chats —
* is declared as data on the `workflow` entry of `FOLDER_RESOURCES`, not branched on here.
*/
export interface PerformCreateFolderParams {
userId: string
@@ -25,7 +20,6 @@ export interface PerformCreateFolderParams {
name: string
id?: string
parentId?: string | null
color?: string
sortOrder?: number
}
@@ -46,317 +40,8 @@ export interface PerformUpdateFolderParams {
sortOrder?: number
}
export interface PerformUpdateFolderResult {
success: boolean
error?: string
errorCode?: OrchestrationErrorCode
folder?: typeof folderTable.$inferSelect
}
export interface PerformUpdateFolderResult extends PerformCreateFolderResult {}
/**
* Verifies that a prospective parent folder exists, belongs to the target
* workspace, and is not archived. Mirrors the validation in the duplicate
* route's `assertTargetParentFolderMutable` so a caller cannot reparent a
* folder to a non-existent id or to a folder in another workspace. Returns
* an error result when invalid, or `null` when the parent is acceptable.
*/
async function assertParentFolderInWorkspace(
parentId: string,
workspaceId: string
): Promise<{ error: string; errorCode: OrchestrationErrorCode } | null> {
const [parent] = await db
.select({
workspaceId: folderTable.workspaceId,
archivedAt: folderTable.deletedAt,
})
.from(folderTable)
.where(and(eq(folderTable.id, parentId), eq(folderTable.resourceType, 'workflow')))
.limit(1)
if (!parent || parent.workspaceId !== workspaceId || parent.archivedAt) {
return { error: 'Parent folder not found', errorCode: 'validation' }
}
return null
}
async function nextFolderSortOrder(
workspaceId: string,
parentId: string | null | undefined
): Promise<number> {
const folderParentCondition = 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(folderTable.sortOrder) })
.from(folderTable)
.where(
and(
eq(folderTable.workspaceId, workspaceId),
eq(folderTable.resourceType, 'workflow'),
folderParentCondition
)
),
db
.select({ minSortOrder: min(workflow.sortOrder) })
.from(workflow)
.where(and(eq(workflow.workspaceId, workspaceId), workflowParentCondition)),
])
const minSortOrder = [folderResult?.minSortOrder, workflowResult?.minSortOrder].reduce<
number | null
>((currentMin, candidate) => {
if (candidate == null) return currentMin
if (currentMin == null) return candidate
return Math.min(currentMin, candidate)
}, null)
return minSortOrder != null ? minSortOrder - 1 : 0
}
export async function performCreateFolder(
params: PerformCreateFolderParams
): Promise<PerformCreateFolderResult> {
try {
const folderId = params.id || generateId()
const parentId = params.parentId || null
if (parentId) {
if (parentId === folderId) {
return {
success: false,
error: 'Folder cannot be its own parent',
errorCode: 'validation',
}
}
const parentError = await assertParentFolderInWorkspace(parentId, params.workspaceId)
if (parentError) return { success: false, ...parentError }
}
const sortOrder =
params.sortOrder !== undefined
? params.sortOrder
: await nextFolderSortOrder(params.workspaceId, parentId)
const [folder] = await db
.insert(folderTable)
.values({
id: folderId,
resourceType: 'workflow',
name: params.name.trim(),
userId: params.userId,
workspaceId: params.workspaceId,
parentId,
sortOrder,
})
.returning()
logger.info('Created workflow folder', { folderId, workspaceId: params.workspaceId, parentId })
recordAudit({
workspaceId: params.workspaceId,
actorId: params.userId,
action: AuditAction.FOLDER_CREATED,
resourceType: AuditResourceType.FOLDER,
resourceId: folderId,
resourceName: folder.name,
description: `Created folder "${folder.name}"`,
metadata: {
name: folder.name,
workspaceId: params.workspaceId,
parentId: parentId || undefined,
sortOrder: folder.sortOrder,
},
})
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' }
}
}
export async function performUpdateFolder(
params: PerformUpdateFolderParams
): Promise<PerformUpdateFolderResult> {
try {
if (params.parentId && params.parentId === params.folderId) {
return { success: false, error: 'Folder cannot be its own parent', errorCode: 'validation' }
}
if (params.parentId) {
const parentError = await assertParentFolderInWorkspace(params.parentId, params.workspaceId)
if (parentError) return { success: false, ...parentError }
const wouldCreateCycle = await checkForCircularReference(params.folderId, params.parentId)
if (wouldCreateCycle) {
return {
success: false,
error: 'Cannot create circular folder reference',
errorCode: 'validation',
}
}
}
// Typed against the table rather than `Record<string, unknown>`: the loose type is what
// let `color`/`isExpanded` survive the cutover here after the create path dropped them.
const updates: Partial<typeof folderTable.$inferInsert> = { updatedAt: new Date() }
if (params.name !== undefined) updates.name = params.name.trim()
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
const [folder] = await db
.update(folderTable)
.set(updates)
.where(
and(
eq(folderTable.id, params.folderId),
eq(folderTable.workspaceId, params.workspaceId),
eq(folderTable.resourceType, 'workflow')
)
)
.returning()
if (!folder) {
return { success: false, error: 'Folder not found', errorCode: 'not_found' }
}
logger.info('Updated workflow folder', { folderId: params.folderId, updates })
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' }
}
}
/**
* Recursively deletes a folder: removes child folders first, archives non-archived
* workflows in each folder via {@link archiveWorkflowsByIdsInWorkspace}, then deletes
* the folder row.
*/
async function deleteFolderRecursively(
folderId: string,
workspaceId: string,
archivedAt?: Date
): Promise<{ folders: number; workflows: number }> {
const timestamp = archivedAt ?? new Date()
const stats = { folders: 0, workflows: 0 }
const childFolders = await db
.select({ id: folderTable.id })
.from(folderTable)
.where(
and(
eq(folderTable.parentId, folderId),
eq(folderTable.workspaceId, workspaceId),
eq(folderTable.resourceType, 'workflow'),
isNull(folderTable.deletedAt)
)
)
for (const childFolder of childFolders) {
const childStats = await deleteFolderRecursively(childFolder.id, workspaceId, timestamp)
stats.folders += childStats.folders
stats.workflows += childStats.workflows
}
const workflowsInFolder = await db
.select({ id: workflow.id })
.from(workflow)
.where(
and(
eq(workflow.folderId, folderId),
eq(workflow.workspaceId, workspaceId),
isNull(workflow.archivedAt)
)
)
if (workflowsInFolder.length > 0) {
await archiveWorkflowsByIdsInWorkspace(
workspaceId,
workflowsInFolder.map((entry) => entry.id),
{ requestId: `folder-${folderId}`, archivedAt: timestamp }
)
stats.workflows += workflowsInFolder.length
}
await db
.update(folderTable)
.set({ deletedAt: timestamp })
.where(and(eq(folderTable.id, folderId), eq(folderTable.resourceType, 'workflow')))
stats.folders += 1
return stats
}
/**
* Counts non-archived workflows in the folder and all descendant folders.
*/
async function countWorkflowsInFolderRecursively(
folderId: string,
workspaceId: string
): Promise<number> {
let count = 0
const workflowsInFolder = await db
.select({ id: workflow.id })
.from(workflow)
.where(
and(
eq(workflow.folderId, folderId),
eq(workflow.workspaceId, workspaceId),
isNull(workflow.archivedAt)
)
)
count += workflowsInFolder.length
const childFolders = await db
.select({ id: folderTable.id })
.from(folderTable)
.where(
and(
eq(folderTable.parentId, folderId),
eq(folderTable.workspaceId, workspaceId),
eq(folderTable.resourceType, 'workflow'),
isNull(folderTable.deletedAt)
)
)
for (const childFolder of childFolders) {
count += await countWorkflowsInFolderRecursively(childFolder.id, workspaceId)
}
return count
}
/** Parameters for {@link performDeleteFolder}. */
export interface PerformDeleteFolderParams {
folderId: string
workspaceId: string
@@ -364,257 +49,42 @@ export interface PerformDeleteFolderParams {
folderName?: string
}
/** Outcome of {@link performDeleteFolder}. */
export interface PerformDeleteFolderResult {
success: boolean
error?: string
errorCode?: OrchestrationErrorCode
deletedItems?: { folders: number; workflows: number }
errorCode?: FolderMutationErrorCode
deletedItems?: { folders: number; workflows?: number }
}
/**
* Performs a full folder deletion: enforces the last-workflow guard,
* recursively archives child workflows and sub-folders, and records
* an audit entry. Both the folders API DELETE handler and the copilot
* delete_folder tool must use this function.
*/
export async function performDeleteFolder(
params: PerformDeleteFolderParams
): Promise<PerformDeleteFolderResult> {
const { folderId, workspaceId, userId, folderName } = params
export interface PerformRestoreFolderParams extends PerformDeleteFolderParams {}
const workflowsInFolder = await countWorkflowsInFolderRecursively(folderId, workspaceId)
const totalWorkflowsInWorkspace = await db
.select({ id: workflow.id })
.from(workflow)
.where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt)))
if (workflowsInFolder > 0 && workflowsInFolder >= totalWorkflowsInWorkspace.length) {
return {
success: false,
error: 'Cannot delete folder containing the only workflow(s) in the workspace',
errorCode: 'validation',
}
}
const deletionStats = await deleteFolderRecursively(folderId, workspaceId)
logger.info('Deleted folder and all contents:', { folderId, deletionStats })
recordAudit({
workspaceId,
actorId: userId,
action: AuditAction.FOLDER_DELETED,
resourceType: AuditResourceType.FOLDER,
resourceId: folderId,
resourceName: folderName,
description: `Deleted folder "${folderName || folderId}"`,
metadata: {
affected: {
workflows: deletionStats.workflows,
subfolders: deletionStats.folders - 1,
},
},
})
return { success: true, deletedItems: deletionStats }
}
/**
* Recursively restores a folder and its children/workflows within a transaction.
* Only restores workflows whose `archivedAt` matches the folder's — workflows
* individually deleted before the folder are left archived.
*/
async function restoreFolderRecursively(
folderId: string,
workspaceId: string,
folderArchivedAt: Date,
tx: Parameters<Parameters<typeof db.transaction>[0]>[0]
): Promise<{ folders: number; workflows: number }> {
const stats = { folders: 0, workflows: 0 }
await tx
.update(folderTable)
.set({ deletedAt: null })
.where(and(eq(folderTable.id, folderId), eq(folderTable.resourceType, 'workflow')))
stats.folders += 1
const archivedWorkflows = await tx
.select({ id: workflow.id })
.from(workflow)
.where(
and(
eq(workflow.folderId, folderId),
eq(workflow.workspaceId, workspaceId),
eq(workflow.archivedAt, folderArchivedAt)
)
)
if (archivedWorkflows.length > 0) {
const workflowIds = archivedWorkflows.map((wf) => wf.id)
const now = new Date()
const restoreSet = { archivedAt: null, updatedAt: now }
await tx.update(workflow).set(restoreSet).where(inArray(workflow.id, workflowIds))
await tx
.update(workflowSchedule)
.set(restoreSet)
.where(inArray(workflowSchedule.workflowId, workflowIds))
await tx.update(webhook).set(restoreSet).where(inArray(webhook.workflowId, workflowIds))
await tx.update(chat).set(restoreSet).where(inArray(chat.workflowId, workflowIds))
await tx
.update(workflowMcpTool)
.set(restoreSet)
.where(inArray(workflowMcpTool.workflowId, workflowIds))
stats.workflows += archivedWorkflows.length
}
const archivedChildren = await tx
.select({ id: folderTable.id })
.from(folderTable)
.where(
and(
eq(folderTable.parentId, folderId),
eq(folderTable.workspaceId, workspaceId),
eq(folderTable.resourceType, 'workflow'),
eq(folderTable.deletedAt, folderArchivedAt)
)
)
for (const child of archivedChildren) {
const childStats = await restoreFolderRecursively(child.id, workspaceId, folderArchivedAt, tx)
stats.folders += childStats.folders
stats.workflows += childStats.workflows
}
return stats
}
/** Parameters for {@link performRestoreFolder}. */
export interface PerformRestoreFolderParams {
folderId: string
workspaceId: string
userId: string
folderName?: string
}
/** Outcome of {@link performRestoreFolder}. */
export interface PerformRestoreFolderResult {
success: boolean
error?: string
restoredItems?: { folders: number; workflows: number }
errorCode?: OrchestrationErrorCode
restoredItems?: { folders: number; workflows?: number }
}
/**
* Restores an archived folder and all its archived children and workflows.
* If the folder's parent is still archived, moves it to the root level.
*/
export async function performRestoreFolder(
export function performCreateFolder(
params: PerformCreateFolderParams
): Promise<PerformCreateFolderResult> {
return createFolder({ ...params, resourceType: 'workflow' })
}
export function performUpdateFolder(
params: PerformUpdateFolderParams
): Promise<PerformUpdateFolderResult> {
return updateFolder({ ...params, resourceType: 'workflow' })
}
export function performDeleteFolder(
params: PerformDeleteFolderParams
): Promise<PerformDeleteFolderResult> {
return deleteFolder({ ...params, resourceType: 'workflow' })
}
export function performRestoreFolder(
params: PerformRestoreFolderParams
): Promise<PerformRestoreFolderResult> {
const { folderId, workspaceId, userId, folderName } = params
const [folder] = await db
.select()
.from(folderTable)
.where(
and(
eq(folderTable.id, folderId),
eq(folderTable.workspaceId, workspaceId),
eq(folderTable.resourceType, 'workflow')
)
)
if (!folder) {
return { success: false, error: 'Folder not found' }
}
if (!folder.deletedAt) {
return { success: true, restoredItems: { folders: 0, workflows: 0 } }
}
const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils')
const ws = await getWorkspaceWithOwner(workspaceId)
if (!ws || ws.archivedAt) {
return { success: false, error: 'Cannot restore folder into an archived workspace' }
}
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 })
.from(folderTable)
.where(and(eq(folderTable.id, folder.parentId), eq(folderTable.resourceType, 'workflow')))
if (!parentFolder || parentFolder.archivedAt) {
resolvedParentId = null
await tx
.update(folderTable)
.set({ parentId: null })
.where(and(eq(folderTable.id, folderId), eq(folderTable.resourceType, 'workflow')))
}
}
// 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,
resolvedParentId,
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) {
// 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 })
recordAudit({
workspaceId,
actorId: userId,
action: AuditAction.FOLDER_RESTORED,
resourceType: AuditResourceType.FOLDER,
resourceId: folderId,
resourceName: folderName ?? folder.name,
description: `Restored folder "${folderName ?? folder.name}"`,
metadata: {
affected: {
workflows: restoredStats.workflows,
subfolders: restoredStats.folders - 1,
},
},
})
return { success: true, restoredItems: restoredStats }
return restoreFolder({ ...params, resourceType: 'workflow' })
}
-30
View File
@@ -507,36 +507,6 @@ export async function verifyFolderWorkspace(
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
): Promise<boolean> {
let currentParentId: string | null = parentId
const visited = new Set<string>()
while (currentParentId) {
if (visited.has(currentParentId) || currentParentId === folderId) {
return true
}
visited.add(currentParentId)
const [parent] = await db
.select({ parentId: folderTable.parentId })
.from(folderTable)
.where(and(eq(folderTable.id, currentParentId), eq(folderTable.resourceType, 'workflow')))
.limit(1)
currentParentId = parent?.parentId || null
}
return false
}
export async function listFolders(workspaceId: string) {
return db
.select({
@@ -0,0 +1,35 @@
import { vi } from 'vitest'
/**
* Controllable mock functions for `@/lib/folders/lifecycle` the generic,
* resourceType-driven folder engine behind every `/api/folders` route.
* All defaults are bare `vi.fn()` configure per-test as needed.
*
* @example
* ```ts
* import { foldersLifecycleMockFns } from '@sim/testing'
*
* foldersLifecycleMockFns.mockCreateFolder.mockResolvedValue({ success: true, folder })
* ```
*/
export const foldersLifecycleMockFns = {
mockCreateFolder: vi.fn(),
mockUpdateFolder: vi.fn(),
mockDeleteFolder: vi.fn(),
mockRestoreFolder: vi.fn(),
}
/**
* Static mock module for `@/lib/folders/lifecycle`.
*
* @example
* ```ts
* vi.mock('@/lib/folders/lifecycle', () => foldersLifecycleMock)
* ```
*/
export const foldersLifecycleMock = {
createFolder: foldersLifecycleMockFns.mockCreateFolder,
updateFolder: foldersLifecycleMockFns.mockUpdateFolder,
deleteFolder: foldersLifecycleMockFns.mockDeleteFolder,
restoreFolder: foldersLifecycleMockFns.mockRestoreFolder,
}
+2
View File
@@ -92,6 +92,8 @@ export {
mockNextFetchResponse,
setupGlobalFetchMock,
} from './fetch.mock'
// Generic folder engine mocks (for @/lib/folders/lifecycle)
export { foldersLifecycleMock, foldersLifecycleMockFns } from './folders-lifecycle.mock'
// Hybrid auth mocks
export { hybridAuthMock, hybridAuthMockFns } from './hybrid-auth.mock'
// Input validation mocks
@@ -24,7 +24,6 @@ export const workflowsUtilsMockFns = {
mockUpdateWorkflowRecord: vi.fn(),
mockDeleteWorkflowRecord: vi.fn(),
mockSetWorkflowVariables: vi.fn(),
mockCheckForCircularReference: vi.fn(),
mockListFolders: vi.fn(),
}
@@ -58,6 +57,5 @@ export const workflowsUtilsMock = {
updateWorkflowRecord: workflowsUtilsMockFns.mockUpdateWorkflowRecord,
deleteWorkflowRecord: workflowsUtilsMockFns.mockDeleteWorkflowRecord,
setWorkflowVariables: workflowsUtilsMockFns.mockSetWorkflowVariables,
checkForCircularReference: workflowsUtilsMockFns.mockCheckForCircularReference,
listFolders: workflowsUtilsMockFns.mockListFolders,
}