mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-29 02:27:35 +08:00
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:
@@ -112,7 +112,8 @@ export const POST = withRouteHandler(
|
||||
tx,
|
||||
targetWorkspaceId,
|
||||
targetParentId,
|
||||
name
|
||||
name,
|
||||
'workflow'
|
||||
)
|
||||
|
||||
await tx.insert(folderTable).values({
|
||||
|
||||
@@ -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 } }
|
||||
)
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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' }])
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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'
|
||||
),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 folder’s 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 resource’s 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user