Skip to content

Commit 06b7898

Browse files
committed
fix(folders): cleanup pass + fix stale cycle-check race in workflow folder update
Cleanup pass over the remaining audit findings (dead code, stale comments, duplicated logic, minor hygiene), plus two real bugs caught along the way: - workflow folder-lifecycle.ts: performUpdateFolder had the same stale cycle-check race already fixed in the generic kb/table path -- the circular-reference check ran against an unlocked pre-transaction snapshot, so two concurrent moves (A under B, B under A) could each pass and commit a persisted cycle. Re-check inside the transaction now, matching the generic path. (Caught by Greptile on the post-audit re-review.) - folders/orchestration.ts: performUpdateFileFolder was swallowing ResourceLockedError into a generic 500 instead of propagating it for the route's 423 handling, unlike its create/delete/restore siblings. - knowledge/[id]/route.ts: the admin-lock-permission check for PUT only verified admin on the KB's *current* workspace, even though workspaceId and locked can change in the same request -- now checks both current and target workspace. Other fixes: extracted duplicated lock+recheck-parent logic in folders/orchestration.ts into a shared helper; removed dead `validUpdates` aliases; removed dead `color` field from workflow folder params; fixed stale comments claiming `locked`/file-folder locking is workflow-only or unused (the generic lock engine treats all four resourceTypes uniformly); added resourceType scoping to log folder-descendant expansion; fixed an O(n*m) descendant-collection loop in the files-download route to build its parent->children index once; added typed TableInvalidFolderError instead of string-matching error messages; fixed a hardcoded SQL column literal in pinned-items lookups; deduplicated FolderResourceType between the folders contract and the folders store; derived a magic query-key array index from the key factory instead of a literal; moved a render-time ref mutation in files.tsx into an effect; memoized a per-render Object.fromEntries in knowledge.tsx; fixed recently-deleted.tsx passing no resourceType on file folder restore (silently defaulted to workflow); aligned a stray eslint-disable in tables.tsx with its file/knowledge siblings. Fixed a knowledge-route test whose hand-rolled db mock had no `transaction` stub (needed after createKnowledgeBase was wrapped in a transaction to close its own folder-lock TOCTOU window) and whose beforeEach was clobbering that stub every test.
1 parent 0711c61 commit 06b7898

24 files changed

Lines changed: 288 additions & 133 deletions

File tree

apps/sim/app/api/folders/reorder/route.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,17 +59,13 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
5959
if (hasInvalidId) {
6060
return NextResponse.json({ error: 'One or more folders were not found' }, { status: 400 })
6161
}
62-
const validUpdates = updates
63-
6462
// A single reorder call operates on one resourceType at a time (the UI never mixes
6563
// folder types in one drag-drop tree). Reject a mixed-type batch explicitly instead
6664
// of silently reordering only the first-seen type and reporting success — a caller
6765
// bug that sends folders from two resource types should surface as an error, not a
6866
// partial `updated` count with no indication some entries were skipped.
69-
const resourceType = resourceTypeById.get(validUpdates[0].id)!
70-
const hasMixedResourceTypes = validUpdates.some(
71-
(u) => resourceTypeById.get(u.id) !== resourceType
72-
)
67+
const resourceType = resourceTypeById.get(updates[0].id)!
68+
const hasMixedResourceTypes = updates.some((u) => resourceTypeById.get(u.id) !== resourceType)
7369
if (hasMixedResourceTypes) {
7470
return NextResponse.json(
7571
{ error: 'All folders in a reorder batch must share the same resourceType' },
@@ -81,7 +77,7 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
8177
// `performReorderFolders` (via `assertFolderParentValid`) below — this route does
8278
// not duplicate that check, it only guards the self-parent case which is a
8379
// cheap synchronous comparison, not a DB round-trip.
84-
for (const update of validUpdates) {
80+
for (const update of updates) {
8581
if (update.parentId && update.parentId === update.id) {
8682
return NextResponse.json({ error: 'Folder cannot be its own parent' }, { status: 400 })
8783
}
@@ -102,13 +98,13 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
10298
for (const folderRow of workspaceFolders) {
10399
parentById.set(folderRow.id, folderRow.parentId)
104100
}
105-
for (const update of validUpdates) {
101+
for (const update of updates) {
106102
if (update.parentId !== undefined) {
107103
parentById.set(update.id, update.parentId || null)
108104
}
109105
}
110106

111-
for (const update of validUpdates) {
107+
for (const update of updates) {
112108
const visited = new Set<string>()
113109
let cursor: string | null = update.id
114110
while (cursor) {
@@ -124,7 +120,7 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
124120
}
125121

126122
const policy = FOLDER_RESOURCE_POLICIES[resourceType]
127-
for (const update of validUpdates) {
123+
for (const update of updates) {
128124
await policy.assertMutable(update.id)
129125
if (update.parentId !== undefined) {
130126
await policy.assertMutable(update.parentId)
@@ -134,7 +130,7 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
134130
const result = await performReorderFolders({
135131
resourceType,
136132
workspaceId,
137-
updates: validUpdates,
133+
updates,
138134
})
139135

140136
if (!result.success) {

apps/sim/app/api/folders/route.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,11 @@ import { performCreateFolder } from '@/lib/folders/orchestration'
99
import { FOLDER_RESOURCE_POLICIES } from '@/lib/folders/policy'
1010
import { listFoldersForWorkspace } from '@/lib/folders/queries'
1111
import { captureServerEvent } from '@/lib/posthog/server'
12+
import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types'
1213
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1314

1415
const logger = createLogger('FoldersAPI')
1516

16-
function folderMutationStatus(errorCode: string | undefined): number {
17-
if (errorCode === 'validation') return 400
18-
if (errorCode === 'conflict') return 409
19-
if (errorCode === 'not_found') return 404
20-
return 500
21-
}
22-
2317
export const GET = withRouteHandler(async (request: NextRequest) => {
2418
try {
2519
const session = await getSession()
@@ -96,7 +90,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
9690
if (!result.success || !result.folder) {
9791
return NextResponse.json(
9892
{ error: result.error },
99-
{ status: folderMutationStatus(result.errorCode) }
93+
{ status: statusForOrchestrationError(result.errorCode) }
10094
)
10195
}
10296

apps/sim/app/api/knowledge/[id]/route.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -98,15 +98,19 @@ export const PUT = withRouteHandler(
9898
const validatedData = parsed.data.body
9999

100100
if (validatedData.locked !== undefined) {
101-
const workspaceId = accessCheck.knowledgeBase.workspaceId
102-
const workspacePermission = workspaceId
103-
? await getUserEntityPermissions(userId, 'workspace', workspaceId)
104-
: null
105-
if (workspacePermission !== 'admin') {
106-
return NextResponse.json(
107-
{ error: 'Admin access required to lock knowledge bases' },
108-
{ status: 403 }
109-
)
101+
const currentWorkspaceId = accessCheck.knowledgeBase.workspaceId
102+
const targetWorkspaceId = validatedData.workspaceId ?? currentWorkspaceId
103+
const workspaceIdsToCheck = new Set(
104+
[currentWorkspaceId, targetWorkspaceId].filter((wsId): wsId is string => Boolean(wsId))
105+
)
106+
for (const wsId of workspaceIdsToCheck) {
107+
const workspacePermission = await getUserEntityPermissions(userId, 'workspace', wsId)
108+
if (workspacePermission !== 'admin') {
109+
return NextResponse.json(
110+
{ error: 'Admin access required to lock knowledge bases' },
111+
{ status: 403 }
112+
)
113+
}
110114
}
111115
}
112116

apps/sim/app/api/knowledge/route.test.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
1414

1515
const { mockDbChain } = vi.hoisted(() => {
16-
const mockDbChain = {
16+
const mockDbChain: Record<string, any> = {
1717
select: vi.fn().mockReturnThis(),
1818
from: vi.fn().mockReturnThis(),
1919
leftJoin: vi.fn().mockReturnThis(),
@@ -24,6 +24,7 @@ const { mockDbChain } = vi.hoisted(() => {
2424
insert: vi.fn().mockReturnThis(),
2525
values: vi.fn().mockResolvedValue(undefined),
2626
}
27+
mockDbChain.transaction = vi.fn((cb: (tx: typeof mockDbChain) => unknown) => cb(mockDbChain))
2728
return { mockDbChain }
2829
})
2930

@@ -44,11 +45,19 @@ describe('Knowledge Base API Route', () => {
4445
Object.values(mockDbChain).forEach((fn) => {
4546
if (typeof fn === 'function') {
4647
fn.mockClear()
47-
if (fn !== mockDbChain.orderBy && fn !== mockDbChain.values && fn !== mockDbChain.limit) {
48+
if (
49+
fn !== mockDbChain.orderBy &&
50+
fn !== mockDbChain.values &&
51+
fn !== mockDbChain.limit &&
52+
fn !== mockDbChain.transaction
53+
) {
4854
fn.mockReturnThis()
4955
}
5056
}
5157
})
58+
mockDbChain.transaction.mockImplementation((cb: (tx: typeof mockDbChain) => unknown) =>
59+
cb(mockDbChain)
60+
)
5261

5362
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin')
5463

apps/sim/app/api/pinned-items/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
import { createLogger } from '@sim/logger'
1111
import { getPostgresErrorCode } from '@sim/utils/errors'
1212
import { generateId } from '@sim/utils/id'
13-
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'
13+
import { and, eq, inArray, isNull } from 'drizzle-orm'
1414
import type { PgColumn, PgTable } from 'drizzle-orm/pg-core'
1515
import { type NextRequest, NextResponse } from 'next/server'
1616
import type { PinnedResourceType } from '@/lib/api/contracts'
@@ -82,7 +82,7 @@ async function resourceExistsInWorkspace(
8282
): Promise<boolean> {
8383
const config = PINNED_RESOURCE_LOOKUP[resourceType]
8484
const [row] = await db
85-
.select({ id: sql<string>`id` })
85+
.select({ id: config.idColumn })
8686
.from(config.resourceTable)
8787
.where(
8888
and(

apps/sim/app/api/table/[tableId]/route.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
88
import { generateRequestId } from '@/lib/core/utils/request'
99
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1010
import { captureServerEvent } from '@/lib/posthog/server'
11-
import { deleteTable, renameTable, TableConflictError, type TableSchema } from '@/lib/table'
11+
import {
12+
deleteTable,
13+
renameTable,
14+
TableConflictError,
15+
TableInvalidFolderError,
16+
type TableSchema,
17+
} from '@/lib/table'
1218
import { getWorkspaceTableLimits } from '@/lib/table/billing'
1319
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1420
import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils'
@@ -168,7 +174,7 @@ export const PATCH = withRouteHandler(
168174
if (error instanceof TableConflictError) {
169175
return NextResponse.json({ error: error.message }, { status: 409 })
170176
}
171-
if (error instanceof Error && error.message.includes('Invalid folderId')) {
177+
if (error instanceof TableInvalidFolderError) {
172178
return NextResponse.json({ error: error.message }, { status: 400 })
173179
}
174180

apps/sim/app/api/table/route.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
createTable,
1313
getWorkspaceTableLimits,
1414
listTables,
15+
TableInvalidFolderError,
1516
type TableSchema,
1617
type TableScope,
1718
} from '@/lib/table'
@@ -148,14 +149,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
148149
return NextResponse.json({ error: error.message }, { status: error.status })
149150
}
150151

152+
if (error instanceof TableInvalidFolderError) {
153+
return NextResponse.json({ error: error.message }, { status: 400 })
154+
}
155+
151156
if (error instanceof Error) {
152157
if (error.message.includes('maximum table limit')) {
153158
return NextResponse.json({ error: error.message }, { status: 403 })
154159
}
155160
if (
156161
error.message.includes('Invalid table name') ||
157162
error.message.includes('Invalid schema') ||
158-
error.message.includes('Invalid folderId') ||
159163
error.message.includes('already exists')
160164
) {
161165
return NextResponse.json({ error: error.message }, { status: 400 })

apps/sim/app/api/workspaces/[id]/files/download/route.ts

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { downloadWorkspaceFileItemsContract } from '@/lib/api/contracts/workspac
66
import { parseRequest } from '@/lib/api/server'
77
import { getSession } from '@/lib/auth'
88
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9-
import { collectDescendantFolderIds } from '@/lib/folders/subtree'
9+
import type { FolderSubtreeRow } from '@/lib/folders/subtree'
1010
import { captureServerEvent } from '@/lib/posthog/server'
1111
import {
1212
buildWorkspaceFileFolderPathMap,
@@ -43,17 +43,32 @@ function withZipPathSuffix(path: string, suffix: number): string {
4343
: `${directory}${filename} (${suffix})`
4444
}
4545

46-
/** Unions each root folder id with every descendant reachable from it. */
47-
function collectSelectedFolderIds(
48-
rootIds: string[],
49-
folders: Array<{ id: string; parentId: string | null }>
50-
): Set<string> {
46+
/**
47+
* Unions each root folder id with every descendant reachable from it.
48+
* Builds the parent→children index once and reuses it across all roots,
49+
* instead of rebuilding it per root as a per-root `collectDescendantFolderIds`
50+
* call would.
51+
*/
52+
function collectSelectedFolderIds(rootIds: string[], folders: FolderSubtreeRow[]): Set<string> {
53+
const childrenByParent = new Map<string, string[]>()
54+
for (const folder of folders) {
55+
if (!folder.parentId) continue
56+
const children = childrenByParent.get(folder.parentId) ?? []
57+
children.push(folder.id)
58+
childrenByParent.set(folder.parentId, children)
59+
}
60+
5161
const folderIds = new Set(rootIds)
52-
for (const rootId of rootIds) {
53-
for (const descendantId of collectDescendantFolderIds(folders, rootId)) {
54-
folderIds.add(descendantId)
62+
const visit = (id: string) => {
63+
for (const childId of childrenByParent.get(id) ?? []) {
64+
if (folderIds.has(childId)) continue
65+
folderIds.add(childId)
66+
visit(childId)
5567
}
5668
}
69+
for (const rootId of rootIds) {
70+
visit(rootId)
71+
}
5772
return folderIds
5873
}
5974

apps/sim/app/workspace/[workspaceId]/files/files.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -599,7 +599,6 @@ export function Files() {
599599
const [prevVisibleRowIds, setPrevVisibleRowIds] = useState(visibleRowIds)
600600
if (prevVisibleRowIds !== visibleRowIds) {
601601
setPrevVisibleRowIds(visibleRowIds)
602-
lastSelectedIndexRef.current = -1
603602
const visible = new Set(visibleRowIds)
604603
setSelectedRowIds((prev) => {
605604
if (prev.size === 0) return prev
@@ -608,6 +607,14 @@ export function Files() {
608607
})
609608
}
610609

610+
// lastSelectedIndexRef is only read inside event handlers, never during
611+
// render/JSX, so it stays a ref — but writing ref.current during render is
612+
// forbidden (react.dev, useRef → "Do not write or read ref.current during
613+
// rendering"), so the reset runs in an Effect keyed on the same transition.
614+
useEffect(() => {
615+
lastSelectedIndexRef.current = -1
616+
}, [visibleRowIds])
617+
611618
const isAllSelected =
612619
visibleRowIds.length > 0 && visibleRowIds.every((id) => selectedRowIds.has(id))
613620
const { selectedFileIds, selectedFolderIds } = useMemo(() => {

apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,7 @@ export function Knowledge() {
314314
)
315315

316316
const folderById = useMemo(() => new Map(folders.map((folder) => [folder.id, folder])), [folders])
317+
const folderByIdRecord = useMemo(() => Object.fromEntries(folderById), [folderById])
317318

318319
const activeFolder = activeFolderId ? (folderById.get(activeFolderId) ?? null) : null
319320

@@ -605,7 +606,7 @@ export function Knowledge() {
605606

606607
const knowledgeBaseInheritedLocked = isFolderOrAncestorLocked(
607608
activeKnowledgeBase?.folderId ?? null,
608-
Object.fromEntries(folderById)
609+
folderByIdRecord
609610
)
610611

611612
const handleToggleKnowledgeBaseLock = useCallback(() => {
@@ -636,7 +637,7 @@ export function Knowledge() {
636637

637638
const folderInheritedLocked = isFolderOrAncestorLocked(
638639
activeFolder?.parentId ?? null,
639-
Object.fromEntries(folderById)
640+
folderByIdRecord
640641
)
641642

642643
const handleToggleFolderLock = useCallback(() => {

0 commit comments

Comments
 (0)