Skip to content

Commit 746051e

Browse files
committed
improvement(folders): suffix the name on restore instead of failing
A 409 is the right answer when the user is choosing a name — create and rename keep it. Restore is different: the caller cannot rename an archived folder, so refusing on a name a sibling has since taken leaves them permanently unable to restore it, with no affordance to resolve the conflict. Restore now deduplicates to "<name> (N)" instead, matching the convention already used by the duplicate route, the client-side create dedup, and the 0272 backfill. The rename happens while the row is still archived, which cannot collide because the unique index only covers active rows. Only the restore root can conflict — descendants come back alongside the siblings they were archived with. Extracts deduplicateFolderName out of the duplicate route into lib/folders/naming.ts now that it has a second caller.
1 parent bda35ab commit 746051e

3 files changed

Lines changed: 69 additions & 32 deletions

File tree

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

Lines changed: 1 addition & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { getSession } from '@/lib/auth'
1212
import { generateRequestId } from '@/lib/core/utils/request'
1313
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1414
import type { DbOrTx } from '@/lib/db/types'
15+
import { deduplicateFolderName } from '@/lib/folders/naming'
1516
import { toFolderApi } from '@/lib/folders/queries'
1617
import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate'
1718
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -265,38 +266,6 @@ async function assertTargetParentFolderMutable(
265266
}
266267
}
267268

268-
async function deduplicateFolderName(
269-
tx: DbOrTx,
270-
workspaceId: string,
271-
parentId: string | null,
272-
requestedName: string
273-
): Promise<string> {
274-
const parentCondition = parentId
275-
? eq(folderTable.parentId, parentId)
276-
: isNull(folderTable.parentId)
277-
const siblingRows = await tx
278-
.select({ name: folderTable.name })
279-
.from(folderTable)
280-
.where(
281-
and(
282-
eq(folderTable.workspaceId, workspaceId),
283-
eq(folderTable.resourceType, 'workflow'),
284-
parentCondition,
285-
isNull(folderTable.deletedAt)
286-
)
287-
)
288-
const siblingNames = new Set(siblingRows.map((row) => row.name))
289-
if (!siblingNames.has(requestedName)) return requestedName
290-
291-
let suffix = 1
292-
let candidate = `${requestedName} (${suffix})`
293-
while (siblingNames.has(candidate)) {
294-
suffix += 1
295-
candidate = `${requestedName} (${suffix})`
296-
}
297-
return candidate
298-
}
299-
300269
async function duplicateFolderStructure(
301270
tx: DbOrTx,
302271
sourceFolderId: string,

apps/sim/lib/folders/naming.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { type db, folder as folderTable } from '@sim/db'
2+
import { and, eq, isNull } from 'drizzle-orm'
3+
import type { FolderResourceType } from '@/lib/api/contracts/folders'
4+
5+
type DbOrTx = Pick<typeof db, 'select'>
6+
7+
/**
8+
* Returns `requestedName`, or the first `"<name> (N)"` variant not already taken by an
9+
* active sibling under `parentId`.
10+
*
11+
* The generic `folder` table has a partial unique index on active
12+
* `(workspaceId, resourceType, parentId, name)`, so any path that makes a row active with a
13+
* caller-supplied name has to either dedup here or handle a 23505. Use this where the user
14+
* has no opportunity to choose a different name (duplicate, restore); return a conflict
15+
* instead where they do (create, rename).
16+
*
17+
* The `" (N)"` shape deliberately matches both the client-side dedup in
18+
* `useFolderCreateWithDedup` and the backfill in migration 0272, so a deduped name reads the
19+
* same however it was produced.
20+
*/
21+
export async function deduplicateFolderName(
22+
tx: DbOrTx,
23+
workspaceId: string,
24+
parentId: string | null,
25+
requestedName: string,
26+
resourceType: FolderResourceType = 'workflow'
27+
): Promise<string> {
28+
const siblingRows = await tx
29+
.select({ name: folderTable.name })
30+
.from(folderTable)
31+
.where(
32+
and(
33+
eq(folderTable.workspaceId, workspaceId),
34+
eq(folderTable.resourceType, resourceType),
35+
parentId ? eq(folderTable.parentId, parentId) : isNull(folderTable.parentId),
36+
isNull(folderTable.deletedAt)
37+
)
38+
)
39+
40+
const siblingNames = new Set(siblingRows.map((row) => row.name))
41+
if (!siblingNames.has(requestedName)) return requestedName
42+
43+
let suffix = 1
44+
while (siblingNames.has(`${requestedName} (${suffix})`)) suffix += 1
45+
return `${requestedName} (${suffix})`
46+
}

apps/sim/lib/workflows/orchestration/folder-lifecycle.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { createLogger } from '@sim/logger'
1212
import { getPostgresErrorCode } from '@sim/utils/errors'
1313
import { generateId } from '@sim/utils/id'
1414
import { and, eq, inArray, isNull, min } from 'drizzle-orm'
15+
import { deduplicateFolderName } from '@/lib/folders/naming'
1516
import { archiveWorkflowsByIdsInWorkspace } from '@/lib/workflows/lifecycle'
1617
import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types'
1718
import { checkForCircularReference } from '@/lib/workflows/utils'
@@ -556,6 +557,27 @@ export async function performRestoreFolder(
556557
}
557558
}
558559

560+
// Restore is a recovery action — the caller cannot rename an archived folder, so a
561+
// name already taken by an active sibling would leave them permanently unable to
562+
// restore. Dedup instead of failing. Safe to rename while the row is still archived:
563+
// the unique index only covers active rows, so this cannot collide before the
564+
// recursive restore below clears `deletedAt`. Only the restore root can conflict —
565+
// descendants come back alongside the siblings they were archived with.
566+
const restoredName = await deduplicateFolderName(
567+
tx,
568+
workspaceId,
569+
folder.parentId,
570+
folder.name
571+
)
572+
if (restoredName !== folder.name) {
573+
logger.info('Renamed folder on restore to avoid a sibling name conflict', {
574+
folderId,
575+
from: folder.name,
576+
to: restoredName,
577+
})
578+
await tx.update(folderTable).set({ name: restoredName }).where(eq(folderTable.id, folderId))
579+
}
580+
559581
return restoreFolderRecursively(folderId, workspaceId, folder.deletedAt!, tx)
560582
})
561583
} catch (error) {

0 commit comments

Comments
 (0)