Skip to content

Commit 1b44716

Browse files
waleedlatif1claude
andcommitted
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>
1 parent 3c6558d commit 1b44716

2 files changed

Lines changed: 53 additions & 35 deletions

File tree

apps/sim/lib/posthog/events.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,11 @@ export interface PostHogEventMap {
441441
workspace_id: string
442442
}
443443

444+
folder_restored: {
445+
folder_id: string
446+
workspace_id: string
447+
}
448+
444449
logs_filter_applied: {
445450
filter_type: 'status' | 'workflow' | 'folder' | 'trigger' | 'time'
446451
workspace_id: string

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

Lines changed: 48 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
workflowSchedule,
1111
} from '@sim/db/schema'
1212
import { createLogger } from '@sim/logger'
13-
import { and, eq, inArray, isNotNull, isNull } from 'drizzle-orm'
13+
import { and, eq, gte, inArray, isNotNull, isNull } from 'drizzle-orm'
1414
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
1515
import { archiveWorkflowsByIdsInWorkspace } from '@/lib/workflows/lifecycle'
1616
import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types'
@@ -179,26 +179,31 @@ export async function performDeleteFolder(
179179
}
180180

181181
/**
182-
* Recursively restores a folder and its archived children: unarchives child folders,
183-
* then restores all archived workflows in each folder.
182+
* Recursively restores a folder and its archived children within a transaction.
183+
* Only restores workflows archived around the same time as the folder (within 5s),
184+
* so individually-deleted workflows are not silently un-deleted.
184185
*/
185186
async function restoreFolderRecursively(
186187
folderId: string,
187-
workspaceId: string
188+
workspaceId: string,
189+
folderArchivedAt: Date,
190+
tx: Parameters<Parameters<typeof db.transaction>[0]>[0]
188191
): Promise<{ folders: number; workflows: number }> {
189192
const stats = { folders: 0, workflows: 0 }
190193

191-
await db.update(workflowFolder).set({ archivedAt: null }).where(eq(workflowFolder.id, folderId))
194+
await tx.update(workflowFolder).set({ archivedAt: null }).where(eq(workflowFolder.id, folderId))
192195
stats.folders += 1
193196

194-
const archivedWorkflows = await db
197+
const archiveWindowStart = new Date(folderArchivedAt.getTime() - 5_000)
198+
const archivedWorkflows = await tx
195199
.select({ id: workflow.id })
196200
.from(workflow)
197201
.where(
198202
and(
199203
eq(workflow.folderId, folderId),
200204
eq(workflow.workspaceId, workspaceId),
201-
isNotNull(workflow.archivedAt)
205+
isNotNull(workflow.archivedAt),
206+
gte(workflow.archivedAt, archiveWindowStart)
202207
)
203208
)
204209

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

210-
await db.transaction(async (tx) => {
211-
await tx.update(workflow).set(restoreSet).where(inArray(workflow.id, workflowIds))
212-
await tx
213-
.update(workflowSchedule)
214-
.set(restoreSet)
215-
.where(inArray(workflowSchedule.workflowId, workflowIds))
216-
await tx.update(webhook).set(restoreSet).where(inArray(webhook.workflowId, workflowIds))
217-
await tx.update(chat).set(restoreSet).where(inArray(chat.workflowId, workflowIds))
218-
await tx.update(form).set(restoreSet).where(inArray(form.workflowId, workflowIds))
219-
await tx
220-
.update(workflowMcpTool)
221-
.set(restoreSet)
222-
.where(inArray(workflowMcpTool.workflowId, workflowIds))
223-
await tx.update(a2aAgent).set(restoreSet).where(inArray(a2aAgent.workflowId, workflowIds))
224-
})
215+
await tx.update(workflow).set(restoreSet).where(inArray(workflow.id, workflowIds))
216+
await tx
217+
.update(workflowSchedule)
218+
.set(restoreSet)
219+
.where(inArray(workflowSchedule.workflowId, workflowIds))
220+
await tx.update(webhook).set(restoreSet).where(inArray(webhook.workflowId, workflowIds))
221+
await tx.update(chat).set(restoreSet).where(inArray(chat.workflowId, workflowIds))
222+
await tx.update(form).set(restoreSet).where(inArray(form.workflowId, workflowIds))
223+
await tx
224+
.update(workflowMcpTool)
225+
.set(restoreSet)
226+
.where(inArray(workflowMcpTool.workflowId, workflowIds))
227+
await tx.update(a2aAgent).set(restoreSet).where(inArray(a2aAgent.workflowId, workflowIds))
225228

226229
stats.workflows += archivedWorkflows.length
227230
}
228231

229-
const archivedChildren = await db
230-
.select({ id: workflowFolder.id })
232+
const archivedChildren = await tx
233+
.select({ id: workflowFolder.id, archivedAt: workflowFolder.archivedAt })
231234
.from(workflowFolder)
232235
.where(
233236
and(
@@ -238,7 +241,12 @@ async function restoreFolderRecursively(
238241
)
239242

240243
for (const child of archivedChildren) {
241-
const childStats = await restoreFolderRecursively(child.id, workspaceId)
244+
const childStats = await restoreFolderRecursively(
245+
child.id,
246+
workspaceId,
247+
child.archivedAt!,
248+
tx
249+
)
242250
stats.folders += childStats.folders
243251
stats.workflows += childStats.workflows
244252
}
@@ -283,18 +291,23 @@ export async function performRestoreFolder(
283291
return { success: false, error: 'Folder is not archived' }
284292
}
285293

286-
if (folder.parentId) {
287-
const [parentFolder] = await db
288-
.select({ archivedAt: workflowFolder.archivedAt })
289-
.from(workflowFolder)
290-
.where(eq(workflowFolder.id, folder.parentId))
291-
292-
if (parentFolder?.archivedAt) {
293-
await db.update(workflowFolder).set({ parentId: null }).where(eq(workflowFolder.id, folderId))
294+
const restoredStats = await db.transaction(async (tx) => {
295+
if (folder.parentId) {
296+
const [parentFolder] = await tx
297+
.select({ archivedAt: workflowFolder.archivedAt })
298+
.from(workflowFolder)
299+
.where(eq(workflowFolder.id, folder.parentId))
300+
301+
if (parentFolder?.archivedAt) {
302+
await tx
303+
.update(workflowFolder)
304+
.set({ parentId: null })
305+
.where(eq(workflowFolder.id, folderId))
306+
}
294307
}
295-
}
296308

297-
const restoredStats = await restoreFolderRecursively(folderId, workspaceId)
309+
return restoreFolderRecursively(folderId, workspaceId, folder.archivedAt!, tx)
310+
})
298311

299312
logger.info('Restored folder and all contents:', { folderId, restoredStats })
300313

0 commit comments

Comments
 (0)