Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix(folders): atomic restore transaction and scope to folder-deleted …
…workflows

Address two review findings:
- Wrap entire folder restore in a single DB transaction to prevent
  partial state if any step fails
- Only restore workflows archived within 5s of the folder's archivedAt,
  so individually-deleted workflows are not silently un-deleted
- Add folder_restored to PostHog event map

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
  • Loading branch information
waleedlatif1 and claude committed Apr 7, 2026
commit 1b447168fc5dc83a95bf2a17808c618bb1b51835
5 changes: 5 additions & 0 deletions apps/sim/lib/posthog/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,11 @@ export interface PostHogEventMap {
workspace_id: string
}

folder_restored: {
folder_id: string
workspace_id: string
}

logs_filter_applied: {
filter_type: 'status' | 'workflow' | 'folder' | 'trigger' | 'time'
workspace_id: string
Expand Down
83 changes: 48 additions & 35 deletions apps/sim/lib/workflows/orchestration/folder-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
workflowSchedule,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, isNotNull, isNull } from 'drizzle-orm'
import { and, eq, gte, inArray, isNotNull, isNull } from 'drizzle-orm'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { archiveWorkflowsByIdsInWorkspace } from '@/lib/workflows/lifecycle'
import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types'
Expand Down Expand Up @@ -179,26 +179,31 @@ export async function performDeleteFolder(
}

/**
* Recursively restores a folder and its archived children: unarchives child folders,
* then restores all archived workflows in each folder.
* Recursively restores a folder and its archived children within a transaction.
* Only restores workflows archived around the same time as the folder (within 5s),
* so individually-deleted workflows are not silently un-deleted.
*/
async function restoreFolderRecursively(
folderId: string,
workspaceId: 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 db.update(workflowFolder).set({ archivedAt: null }).where(eq(workflowFolder.id, folderId))
await tx.update(workflowFolder).set({ archivedAt: null }).where(eq(workflowFolder.id, folderId))
stats.folders += 1

const archivedWorkflows = await db
const archiveWindowStart = new Date(folderArchivedAt.getTime() - 5_000)
const archivedWorkflows = await tx
.select({ id: workflow.id })
.from(workflow)
.where(
and(
eq(workflow.folderId, folderId),
eq(workflow.workspaceId, workspaceId),
isNotNull(workflow.archivedAt)
isNotNull(workflow.archivedAt),
gte(workflow.archivedAt, archiveWindowStart)
)
)
Comment thread
waleedlatif1 marked this conversation as resolved.

Expand All @@ -207,27 +212,25 @@ async function restoreFolderRecursively(
const now = new Date()
const restoreSet = { archivedAt: null, updatedAt: now }

await db.transaction(async (tx) => {
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(form).set(restoreSet).where(inArray(form.workflowId, workflowIds))
await tx
.update(workflowMcpTool)
.set(restoreSet)
.where(inArray(workflowMcpTool.workflowId, workflowIds))
await tx.update(a2aAgent).set(restoreSet).where(inArray(a2aAgent.workflowId, workflowIds))
})
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(form).set(restoreSet).where(inArray(form.workflowId, workflowIds))
await tx
.update(workflowMcpTool)
.set(restoreSet)
.where(inArray(workflowMcpTool.workflowId, workflowIds))
await tx.update(a2aAgent).set(restoreSet).where(inArray(a2aAgent.workflowId, workflowIds))

stats.workflows += archivedWorkflows.length
}

const archivedChildren = await db
.select({ id: workflowFolder.id })
const archivedChildren = await tx
.select({ id: workflowFolder.id, archivedAt: workflowFolder.archivedAt })
.from(workflowFolder)
.where(
and(
Expand All @@ -238,7 +241,12 @@ async function restoreFolderRecursively(
)

for (const child of archivedChildren) {
const childStats = await restoreFolderRecursively(child.id, workspaceId)
const childStats = await restoreFolderRecursively(
child.id,
workspaceId,
child.archivedAt!,
tx
)
stats.folders += childStats.folders
stats.workflows += childStats.workflows
}
Comment thread
waleedlatif1 marked this conversation as resolved.
Expand Down Expand Up @@ -283,18 +291,23 @@ export async function performRestoreFolder(
return { success: false, error: 'Folder is not archived' }
}

if (folder.parentId) {
const [parentFolder] = await db
.select({ archivedAt: workflowFolder.archivedAt })
.from(workflowFolder)
.where(eq(workflowFolder.id, folder.parentId))

if (parentFolder?.archivedAt) {
await db.update(workflowFolder).set({ parentId: null }).where(eq(workflowFolder.id, folderId))
const restoredStats = await db.transaction(async (tx) => {
if (folder.parentId) {
const [parentFolder] = await tx
.select({ archivedAt: workflowFolder.archivedAt })
.from(workflowFolder)
.where(eq(workflowFolder.id, folder.parentId))

if (parentFolder?.archivedAt) {
Comment thread
waleedlatif1 marked this conversation as resolved.
Outdated
await tx
.update(workflowFolder)
.set({ parentId: null })
.where(eq(workflowFolder.id, folderId))
}
}
}

const restoredStats = await restoreFolderRecursively(folderId, workspaceId)
return restoreFolderRecursively(folderId, workspaceId, folder.archivedAt!, tx)
})

Comment thread
waleedlatif1 marked this conversation as resolved.
logger.info('Restored folder and all contents:', { folderId, restoredStats })

Expand Down
Loading