Skip to content

Commit d1cdc6a

Browse files
committed
fix(folders): close the findings from the line-by-line audit of the PR
Data integrity - Workspace fork copied `folderId` verbatim onto the child's tables and knowledge bases. The column carries no workspace, so a forked row pointed at a folder owned by the SOURCE workspace — invisible in the fork, and mutated from under it when the source later deleted that folder (`ON DELETE SET NULL`). Latent until now because nothing wrote a non-null `folderId` for those two resources; this PR is what makes it reachable. Forked resources land at the root, like forked files already did Interaction bugs - Clearing a dead `?folderId=` wrote through the params' default `history: 'push'`, so Back returned to the dead URL, which healed and pushed again — the Back button was dead on that page. It replaces now: opening a folder is navigation, correcting a URL that never pointed anywhere is not - That same heal keyed off `isLoading`, which is false for a DISABLED query, false for an ERRORED one, and false while `keepPreviousData` is showing the previous workspace's folders. In all three the list is empty or stale, so a transient fetch failure — or a workspace switch — threw away a perfectly good open folder. Gated on `isSuccess && !isPlaceholderData` - "New folder" while a search was active created the folder but never showed it: the search filters folders too, so the new row did not match, the rename field never appeared, and the create read as a no-op. Both pages clear the search so the thing just created is on screen - The drag ghost was removed only by `dragend`, which fires on the source row. The table is virtualized, so scrolling the source out of view mid-drag unmounted it, the event never arrived, and the ghost stayed on the page with every row stuck at drag opacity. Cleaned up on unmount as the backstop - A drag begun in another mount highlighted every folder as a valid target — including the dragged folder itself — then silently did nothing on drop. It only highlights when the source is known and was actually checked Correctness - The folder-duplicate route caught 23505 across the WHOLE handler, so a unique violation raised while copying the workflows inside the folder was relabelled "a folder with this name already exists". Scoped to the folder INSERT, which is the only place the two conflicts the caller can act on (client-supplied `newId` replay, concurrent name take) actually arise - The optimistic folder create derived its sort order from the workspace's WORKFLOWS regardless of resource type. Only the workflow tree interleaves folders and resources in one ordering space, so a knowledge-base or table folder was placed against an unrelated one - A table archived between `checkAccess` and the move surfaced as a 500 with a "not found" body; it is a 404 - `FOLDER_RESOURCE_TYPE_BY_RESTORABLE` was a `Partial` record, so adding a folder tree without a mapping would compile and silently restore into the workflow tree. Total record now - Recently Deleted paired query results to folder trees by ARRAY POSITION; reordering the const would have filed knowledge folders under Tables with no type error. Keyed by type - The knowledge move's no-op guard compared against the snapshot taken when the menu opened rather than the live row Performance — the module split now actually does what it claimed - `knowledge-keys.ts` justified itself as keeping server prefetches off the ~1000-line `kb/knowledge` module, but `knowledge/prefetch.ts` still imported its stale-time constant from exactly that module, whose first line pulls the `@sim/emcn` barrel. Worse, this PR had ADDED the same class of edge to four prefetches via `FOLDER_LIST_STALE_TIME`/`mapFolder` - `FOLDER_LIST_STALE_TIME`, `mapFolder`, and `KNOWLEDGE_BASE_LIST_STALE_TIME` now live beside their key factories, so all four server prefetches import only keys-and-constants modules. `mapFolder` keeps its explicit field list rather than a spread, so the cached shape cannot drift from a client fetch Honesty in comments - The KB retention `deleteFilter` narrows the restore race but does not close it — documents hard-deleted by `onBatch` do not come back, so a restore landing mid-batch returns an emptied base. Said so - A failed folder restore leaves children active while their folders are still archived. They are reachable (both lists fall an unresolvable `folderId` back to the root) but they sit at the root until the restore is retried. Said so Product policy made visible - Restore deliberately does not re-enable schedules, webhooks, or chats, because archive overwrites their state without recording it — on production ~47% of live schedules sit at `disabled` and ~12% of live webhooks at `isActive: false`, so reactivating to a constant would be wrong far more often than right. Recently Deleted now says so at the moment of restore instead of leaving it to be discovered Smaller - Pin glyph: dropped the doubled gap, added `role='img'` so the state is announced - "Move here" carries the folder glyph its sibling leaves do - `useMoveTable` had stolen `useDeleteTable`'s doc block - Dead `.returning` column and a `?? null` on a non-nullable param in `moveTableToFolder` - Tables reads one sort source for both blocks, matching Knowledge - New test pins that `updateFolder` refuses an archived folder — the guard that stops a delete from unlocking its locked subfolders
1 parent 01dc6a9 commit d1cdc6a

25 files changed

Lines changed: 279 additions & 104 deletions

File tree

apps/sim/app/api/folders/[id]/duplicate/route.ts

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -123,18 +123,38 @@ export const POST = withRouteHandler(
123123
FOLDER_RESOURCE_TYPE
124124
)
125125

126-
await tx.insert(folderTable).values({
127-
id: newFolderId,
128-
resourceType: FOLDER_RESOURCE_TYPE,
129-
userId: session.user.id,
130-
workspaceId: targetWorkspaceId,
131-
name: deduplicatedName,
132-
parentId: targetParentId,
133-
sortOrder,
134-
locked: false,
135-
createdAt: now,
136-
updatedAt: now,
137-
})
126+
try {
127+
await tx.insert(folderTable).values({
128+
id: newFolderId,
129+
resourceType: FOLDER_RESOURCE_TYPE,
130+
userId: session.user.id,
131+
workspaceId: targetWorkspaceId,
132+
name: deduplicatedName,
133+
parentId: targetParentId,
134+
sortOrder,
135+
locked: false,
136+
createdAt: now,
137+
updatedAt: now,
138+
})
139+
} catch (insertError) {
140+
/**
141+
* Scoped to THIS insert on purpose. A 23505 here is one of two real conflicts the
142+
* caller can act on: `newId` is client-supplied, so replaying a duplicate whose
143+
* response was lost hits the primary key; and `deduplicateFolderName` runs before
144+
* the write, so a concurrent create can still take the name in between.
145+
*
146+
* Catching 23505 across the whole handler instead would relabel any unique violation
147+
* raised while copying the workflows inside the folder — a different constraint, on a
148+
* different table — as a folder-name conflict, which is both wrong and misleading.
149+
* Those keep falling through to the generic 500.
150+
*/
151+
if (getPostgresErrorCode(insertError) !== '23505') throw insertError
152+
throw new FolderDuplicationError(
153+
`Folder duplication conflicted for ${sourceFolderId}`,
154+
409,
155+
'A folder with this name already exists in this location'
156+
)
157+
}
138158

139159
const folderMapping = new Map<string, string>([[sourceFolderId, newFolderId]])
140160
await duplicateFolderStructure(
@@ -201,20 +221,6 @@ export const POST = withRouteHandler(
201221
return NextResponse.json({ error: error.message }, { status: error.status })
202222
}
203223

204-
/**
205-
* `newId` is client-supplied, so replaying a duplicate whose response was lost hits the
206-
* primary key rather than the name index. A name collision is equally reachable: dedup
207-
* runs before the INSERT, but a concurrent create can take the name in between. Either
208-
* way this is a conflict the caller can act on, not a server fault.
209-
*/
210-
if (getPostgresErrorCode(error) === '23505') {
211-
logger.warn(`[${requestId}] Folder duplication conflicted`, { sourceFolderId })
212-
return NextResponse.json(
213-
{ error: 'A folder with this name already exists in this location' },
214-
{ status: 409 }
215-
)
216-
}
217-
218224
if (error instanceof FolderDuplicationError) {
219225
logger.warn(`[${requestId}] Folder duplication rejected: ${error.message}`, {
220226
sourceFolderId,

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

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -196,13 +196,22 @@ export const PATCH = withRouteHandler(
196196
) {
197197
return NextResponse.json({ error: 'Folder not found in this workspace' }, { status: 404 })
198198
}
199-
await moveTableToFolder(
200-
tableId,
201-
table.workspaceId,
202-
validated.folderId,
203-
requestId,
204-
authResult.userId
205-
)
199+
try {
200+
await moveTableToFolder(
201+
tableId,
202+
table.workspaceId,
203+
validated.folderId,
204+
requestId,
205+
authResult.userId
206+
)
207+
} catch (moveError) {
208+
// The move re-asserts workspace and active state, so a miss means the table was
209+
// archived between `checkAccess` and the write. That is a 404, not a server fault.
210+
if (moveError instanceof Error && moveError.message.endsWith('not found')) {
211+
return NextResponse.json({ error: 'Table not found' }, { status: 404 })
212+
}
213+
throw moveError
214+
}
206215
}
207216

208217
// Re-read so the response reflects both a rename and a lock change.

apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { ElementType } from 'react'
12
import type {
23
BreadcrumbEditing,
34
BreadcrumbItem,
@@ -8,7 +9,7 @@ import type { WorkflowFolder } from '@/stores/folders/types'
89
export interface FolderBreadcrumbItemsOptions {
910
/** Root crumb label — the page's own name ("Knowledge Base", "Tables"). */
1011
rootLabel: string
11-
rootIcon?: React.ElementType
12+
rootIcon?: ElementType
1213
/** Root-first ancestor chain of the open folder, from `useFolderNavigation`. */
1314
breadcrumbs: WorkflowFolder[]
1415
/** Called with the folder to open, or `null` for the workspace root. */
@@ -46,10 +47,9 @@ export function folderBreadcrumbItems({
4647

4748
breadcrumbs.forEach((folder, index) => {
4849
const isCurrent = index === breadcrumbs.length - 1
50+
/** The open folder is where you already are, so its crumb is not a navigation target. */
4951
items.push({
5052
label: folder.name,
51-
// The current folder is where you already are, so its crumb is not a navigation
52-
// target — it carries the folder's own actions instead.
5353
onClick: isCurrent ? undefined : () => onNavigate(folder.id),
5454
dropdownItems: isCurrent && currentFolderActions?.length ? currentFolderActions : undefined,
5555
editing: isCurrent ? currentFolderEditing : undefined,

apps/sim/app/workspace/[workspaceId]/components/folders/move-options.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ export interface BuildMoveOptionsParams {
4242
*
4343
* Children are indexed by parent once so the tree walk stays linear in the number of
4444
* folders rather than re-filtering the whole list at every level.
45+
*
46+
* `buildSubtree` needs no cycle guard, and that is a property of the walk rather than an
47+
* oversight: it descends from the `null` root, and every folder inside a parent cycle has a
48+
* parent inside that cycle, so no cycle member is ever reachable from the root. Such folders
49+
* are omitted from the menu, which is the right answer — they are not valid destinations.
4550
*/
4651
export function buildMoveOptions({
4752
folders,
@@ -131,7 +136,10 @@ export function renderMoveOption(
131136
{option.label}
132137
</DropdownMenuSubTrigger>
133138
<DropdownMenuSubContent>
134-
<DropdownMenuItem onSelect={() => onMove(option.value)}>Move here</DropdownMenuItem>
139+
<DropdownMenuItem onSelect={() => onMove(option.value)}>
140+
<Folder />
141+
Move here
142+
</DropdownMenuItem>
135143
<DropdownMenuSeparator />
136144
{option.children.map((child) => renderMoveOption(child, onMove))}
137145
</DropdownMenuSubContent>

apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,22 @@ export function useFolderNavigation({
5252
folderNavUrlKeys
5353
)
5454

55-
const { data: folders = EMPTY_FOLDERS, isLoading } = useFolders(workspaceId, { resourceType })
55+
const {
56+
data: folders = EMPTY_FOLDERS,
57+
isLoading,
58+
isSuccess,
59+
isPlaceholderData,
60+
} = useFolders(workspaceId, { resourceType })
61+
62+
/**
63+
* The folder list is only trustworthy enough to evict a `folderId` when the query has
64+
* actually succeeded for THIS workspace. `isLoading` alone is not that signal: it is false
65+
* for a disabled query (no `workspaceId`), false for an errored one, and — because
66+
* `useFolders` sets `keepPreviousData` — false while showing the previous workspace's
67+
* folders during a workspace switch. In all three the list is empty or stale, and healing
68+
* off it would throw away a perfectly good folder.
69+
*/
70+
const foldersAreAuthoritative = isSuccess && !isPlaceholderData
5671

5772
const setCurrentFolderId = useCallback(
5873
(folderId: string | null) => {
@@ -80,9 +95,15 @@ export function useFolderNavigation({
8095
* Waits for `isLoading` so an empty index mid-fetch never evicts a perfectly good id.
8196
*/
8297
useEffect(() => {
83-
if (isLoading || !currentFolderId || folderById.has(currentFolderId)) return
84-
void setFolderParams({ folderId: null })
85-
}, [isLoading, currentFolderId, folderById, setFolderParams])
98+
if (!foldersAreAuthoritative || !currentFolderId || folderById.has(currentFolderId)) return
99+
/**
100+
* `history: 'replace'`, overriding the `push` these params default to. Opening a folder is
101+
* a navigation and belongs in the back stack; correcting a URL that never pointed anywhere
102+
* is not. Pushing here strands the user: Back returns to the dead `?folderId=`, which heals
103+
* and pushes again, so Back never escapes the page.
104+
*/
105+
void setFolderParams({ folderId: null }, { history: 'replace' })
106+
}, [foldersAreAuthoritative, currentFolderId, folderById, setFolderParams])
86107

87108
const breadcrumbs = useMemo(() => {
88109
if (!currentFolderId) return EMPTY_FOLDERS

apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { type DragEvent, useCallback, useMemo, useRef, useState } from 'react'
3+
import { type DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
44
import { parseFolderedRowId } from '@/app/workspace/[workspaceId]/components/folders/folder-row-id'
55
import type { RowDragDropConfig } from '@/app/workspace/[workspaceId]/components/resource/resource'
66

@@ -81,6 +81,20 @@ export function useFolderRowDragDrop({
8181
onMoveResource,
8282
}
8383

84+
/**
85+
* The ghost lives on `document.body`, but the only thing that removes it is `dragend`, which
86+
* fires on the SOURCE ROW. `Resource.Table` is virtualized, so scrolling the source out of
87+
* view mid-drag unmounts that row and the event never arrives — leaving the ghost stuck on
88+
* the page and every row frozen at drag opacity. Clean up on unmount as the backstop.
89+
*/
90+
useEffect(
91+
() => () => {
92+
dragGhostRef.current?.remove()
93+
dragGhostRef.current = null
94+
},
95+
[]
96+
)
97+
8498
const isInvalidDropTarget = useCallback((targetRowId: string, sourceRowId: string) => {
8599
const target = parseFolderedRowId(targetRowId)
86100
if (target.kind !== 'folder') return true
@@ -146,7 +160,12 @@ export function useFolderRowDragDrop({
146160
e.preventDefault()
147161
e.stopPropagation()
148162
e.dataTransfer.dropEffect = 'move'
149-
setActiveDropTargetId(rowId)
163+
/**
164+
* Highlight only when the source is known and was checked. Without it every folder
165+
* would light up as a valid target — including the dragged folder itself and its own
166+
* descendants — and the drop would then silently do nothing.
167+
*/
168+
if (sourceRowId) setActiveDropTargetId(rowId)
150169
},
151170
onDragLeave: (e: DragEvent<HTMLDivElement>, rowId) => {
152171
const relatedTarget = e.relatedTarget

apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ export interface ResourceCell {
6464
* rows sort to the top of every list, and without an indicator that ordering reads as
6565
* arbitrary. Pinning itself is an action on the row's context menu, deliberately not a
6666
* hover button here.
67+
*
68+
* Honoured only on the plain label cell: a cell rendering custom `content` owns its own
69+
* layout, and the rename field replaces the label entirely while it is open.
6770
*/
6871
pinned?: boolean
6972
}
@@ -526,7 +529,11 @@ const CellContent = memo(function CellContent({
526529
{icon && <span className={cellIconNodeClass}>{icon}</span>}
527530
<FloatingOverflowText label={label} className={cn('block', chipContentLabelClass)} />
528531
{pinned && (
529-
<Pin className='ml-1.5 size-[12px] shrink-0 text-[var(--text-icon)]' aria-label='Pinned' />
532+
<Pin
533+
className='size-[12px] shrink-0 text-[var(--text-icon)]'
534+
role='img'
535+
aria-label='Pinned'
536+
/>
530537
)}
531538
</span>
532539
)

apps/sim/app/workspace/[workspaceId]/home/prefetch.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import type { QueryClient } from '@tanstack/react-query'
22
import type { FolderApi } from '@/lib/api/contracts'
33
import type { ListWorkspaceFilesResponse } from '@/lib/api/contracts/workspace-files'
44
import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch'
5-
import { FOLDER_LIST_STALE_TIME, mapFolder } from '@/hooks/queries/folders'
6-
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
5+
import { FOLDER_LIST_STALE_TIME, folderKeys, mapFolder } from '@/hooks/queries/utils/folder-keys'
76
import {
87
WORKSPACE_FILES_LIST_STALE_TIME,
98
workspaceFilesKeys,

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,13 @@ export function Knowledge() {
691691
name,
692692
parentId: parentId ?? undefined,
693693
})
694+
/**
695+
* A live search term filters the folder list too, so a brand-new "New folder" would not
696+
* match it — the row never renders, the rename field never appears, and the create reads
697+
* as a no-op even though it succeeded. Clear the search so the thing just created is on
698+
* screen to be named.
699+
*/
700+
setSearchQuery('')
694701
// Drop straight into rename: the auto-generated name is a placeholder, and the user
695702
// should not have to hunt for a second action to replace it.
696703
listRenameRef.current.startRename(folderRowId(folder.id), folder.name)
@@ -834,7 +841,10 @@ export function Knowledge() {
834841
const kb = activeKnowledgeBaseRef.current
835842
if (!kb) return
836843
const folderId = parseMoveOptionValue(optionValue)
837-
if ((kb.folderId ?? null) !== folderId) await moveKnowledgeBaseTo(kb.id, folderId)
844+
// Re-read placement from the live list: `activeKnowledgeBase` is a snapshot from when
845+
// the menu opened, and a refetch since then would make the no-op check wrong.
846+
const current = knowledgeBasesRef.current.find((item) => item.id === kb.id) ?? kb
847+
if ((current.folderId ?? null) !== folderId) await moveKnowledgeBaseTo(kb.id, folderId)
838848
closeRowContextMenu()
839849
},
840850
[moveKnowledgeBaseTo, closeRowContextMenu]

apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,8 @@ import type { QueryClient } from '@tanstack/react-query'
22
import type { FolderApi } from '@/lib/api/contracts/folders'
33
import type { KnowledgeBaseData } from '@/lib/api/contracts/knowledge'
44
import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch'
5-
import { FOLDER_LIST_STALE_TIME, mapFolder } from '@/hooks/queries/folders'
6-
import { KNOWLEDGE_BASE_LIST_STALE_TIME } from '@/hooks/queries/kb/knowledge'
7-
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
8-
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
5+
import { FOLDER_LIST_STALE_TIME, folderKeys, mapFolder } from '@/hooks/queries/utils/folder-keys'
6+
import { KNOWLEDGE_BASE_LIST_STALE_TIME, knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
97

108
/**
119
* Prefetches the workspace's knowledge-bases list AND its knowledge-base folder tree under

0 commit comments

Comments
 (0)