Skip to content

Commit 84fcd6c

Browse files
committed
fix(folders): order the cascade so a partial failure stays recoverable
The hooks that walk resources one at a time through their canonical delete/restore can fail partway. Both cascades were ordered so that a mid-loop failure became permanent. Delete stamped children before folders. A failure left some children soft-deleted at T1 with the folder still active, so the retry minted a fresh T2 and the T1 children could never be matched by any restore. Folders are now stamped first, which makes the retry reuse that stamp and sweep the stragglers into the original snapshot. Restore had the mirror image: folders came back first, so a later child failure left the root's deletedAt cleared and every retry short-circuited on the already-restored early return, stranding whatever had not come back. Hook-driven child restores now run before the folder transaction, so a failure leaves the folder archived and the whole restore retryable. Both orderings trade a transient window where the folder and its contents disagree for a failure that heals on retry instead of silently losing data. Also rethrow HttpError from the folder DELETE handler: deleteTable can still raise a 423 TableLockedError if a lock lands between the subtree guard and the archive, and the catch was flattening it to a 500.
1 parent a28eb45 commit 84fcd6c

4 files changed

Lines changed: 83 additions & 53 deletions

File tree

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { type NextRequest, NextResponse } from 'next/server'
77
import { deleteFolderContract, updateFolderContract } from '@/lib/api/contracts'
88
import { parseRequest } from '@/lib/api/server'
99
import { getSession } from '@/lib/auth'
10+
import { HttpError } from '@/lib/core/utils/http-error'
1011
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1112
import { deleteFolder, updateFolder } from '@/lib/folders/lifecycle'
1213
import { toFolderApi } from '@/lib/folders/queries'
@@ -187,6 +188,11 @@ export const DELETE = withRouteHandler(
187188
return NextResponse.json({ error: error.message }, { status: error.status })
188189
}
189190

191+
// A typed domain error carries its own status — `deleteTable` can still raise a 423
192+
// `TableLockedError` if a lock is set between the subtree guard and the archive.
193+
// Rethrow so `withRouteHandler` maps it instead of flattening it to a 500.
194+
if (error instanceof HttpError) throw error
195+
190196
logger.error('Error deleting folder:', { error })
191197
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
192198
}

apps/sim/lib/folders/cascade.test.ts

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -165,21 +165,39 @@ describe('collectArchivedSubtreeIds', () => {
165165
})
166166

167167
describe('archiveFolderCascade', () => {
168-
it('archives children then folders under one shared timestamp', async () => {
168+
it('stamps folders before children, under one shared timestamp', async () => {
169169
const { tx, updateCalls } = makeTx({
170170
updates: [
171-
[{ id: 'child-1' }, { id: 'child-2' }],
172171
[{ id: 'root' }, { id: 'sub' }],
172+
[{ id: 'child-1' }, { id: 'child-2' }],
173173
],
174174
})
175175

176176
const counts = await archiveFolderCascade(tx, makeConfig(), 'ws-1', ['root', 'sub'], TIMESTAMP)
177177

178178
expect(counts).toEqual({ folders: 2, children: 2 })
179179
expect(updateCalls).toHaveLength(2)
180-
expect(updateCalls[0].table).toBe(CHILD_TABLE)
181-
expect(updateCalls[0].set).toEqual({ archivedAt: TIMESTAMP, updatedAt: TIMESTAMP })
182-
expect(updateCalls[1].set).toMatchObject({ deletedAt: TIMESTAMP })
180+
// Order is load-bearing: the folder must carry the stamp before any child does, so a
181+
// failed cascade can be retried onto the same snapshot instead of minting a new one.
182+
expect(updateCalls[0].table).not.toBe(CHILD_TABLE)
183+
expect(updateCalls[0].set).toMatchObject({ deletedAt: TIMESTAMP })
184+
expect(updateCalls[1].table).toBe(CHILD_TABLE)
185+
expect(updateCalls[1].set).toEqual({ archivedAt: TIMESTAMP, updatedAt: TIMESTAMP })
186+
})
187+
188+
it('stamps the folder before invoking an archiveChildren hook', async () => {
189+
const seenAtHookTime: number[] = []
190+
const { tx, updateCalls } = makeTx({ updates: [[{ id: 'root' }]] })
191+
const archiveChildren = vi.fn().mockImplementation(async () => {
192+
seenAtHookTime.push(updateCalls.length)
193+
return 3
194+
})
195+
196+
await archiveFolderCascade(tx, makeConfig({ archiveChildren }), 'ws-1', ['root'], TIMESTAMP)
197+
198+
// The hook walks resources one at a time and can fail partway, so the folder stamp must
199+
// already be in place by then for the retry to reuse it.
200+
expect(seenAtHookTime).toEqual([1])
183201
})
184202

185203
it('skips rows that were already soft-deleted independently', async () => {
@@ -192,16 +210,16 @@ describe('archiveFolderCascade', () => {
192210
}
193211
})
194212

195-
it('stamps children with the timestamp it is given, not one of its own', async () => {
196-
const { tx, updateCalls } = makeTx({ updates: [[{ id: 'child-1' }], []] })
213+
it('stamps every row with the timestamp it is given, not one of its own', async () => {
214+
const { tx, updateCalls } = makeTx({ updates: [[{ id: 'root' }], [{ id: 'child-1' }]] })
197215

198216
await archiveFolderCascade(tx, makeConfig(), 'ws-1', ['root'], TIMESTAMP)
199217

200218
// Regression guard: deleting an already-archived folder reuses that folder's existing
201219
// deletedAt. A fresh stamp here would strand the children — the folder row keeps its
202220
// original stamp, so restore would never match them.
203-
expect(updateCalls[0].set).toEqual({ archivedAt: TIMESTAMP, updatedAt: TIMESTAMP })
204-
expect(updateCalls[1].set).toMatchObject({ deletedAt: TIMESTAMP })
221+
expect(updateCalls[0].set).toMatchObject({ deletedAt: TIMESTAMP })
222+
expect(updateCalls[1].set).toEqual({ archivedAt: TIMESTAMP, updatedAt: TIMESTAMP })
205223
})
206224

207225
it('delegates to archiveChildren when a resource archives through its own lifecycle', async () => {

apps/sim/lib/folders/cascade.ts

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,21 @@ function childFilter(config: FolderResourceConfig, workspaceId: string, folderId
8080
* Soft-deletes every folder in `folderIds` and every resource contained by them, stamping
8181
* one shared `timestamp` across the whole cascade.
8282
*
83-
* The shared timestamp is load-bearing: {@link restoreFolderCascade} resurrects only rows
84-
* whose soft-delete timestamp matches the folder's exactly, which is what stops a restore
85-
* from also reviving siblings that were deleted independently.
83+
* The shared timestamp is load-bearing: the restore path resurrects only rows whose
84+
* soft-delete timestamp matches the folder's exactly, which is what stops a restore from
85+
* also reviving siblings that were deleted independently.
8686
*
87-
* Children are archived before folders so a concurrent reader never sees an active
88-
* resource inside a folder that has already vanished from the tree.
87+
* Folders are stamped BEFORE their children, which is what makes a failed cascade
88+
* recoverable. `archiveChildren` hooks walk resources one at a time through their canonical
89+
* delete, so a mid-loop failure can leave some children archived and some not. With the
90+
* folder already stamped, `deleteFolder` reuses that same `deletedAt` on the retry and the
91+
* stragglers join the original snapshot. Stamping children first would leave the folder
92+
* active, so a retry would mint a fresh timestamp and the partially-archived children could
93+
* never be matched by any restore again.
94+
*
95+
* The cost is a window where the folder reads as deleted while a resource inside it is still
96+
* active. That is the strictly better failure: it is transient and self-healing on retry,
97+
* where the alternative silently and permanently strands data.
8998
*/
9099
export async function archiveFolderCascade(
91100
tx: DbOrTx,
@@ -94,16 +103,6 @@ export async function archiveFolderCascade(
94103
folderIds: string[],
95104
timestamp: Date
96105
): Promise<FolderCascadeCounts> {
97-
const children = config.archiveChildren
98-
? await config.archiveChildren({ workspaceId, folderIds, timestamp })
99-
: (
100-
await tx
101-
.update(config.table)
102-
.set(config.buildSoftDeleteSet(timestamp, timestamp))
103-
.where(and(childFilter(config, workspaceId, folderIds), isNull(config.deletedColumn)))
104-
.returning({ id: config.idColumn })
105-
).length
106-
107106
const archivedFolders = await tx
108107
.update(folderTable)
109108
.set({ deletedAt: timestamp, updatedAt: timestamp })
@@ -117,6 +116,16 @@ export async function archiveFolderCascade(
117116
)
118117
.returning({ id: folderTable.id })
119118

119+
const children = config.archiveChildren
120+
? await config.archiveChildren({ workspaceId, folderIds, timestamp })
121+
: (
122+
await tx
123+
.update(config.table)
124+
.set(config.buildSoftDeleteSet(timestamp, timestamp))
125+
.where(and(childFilter(config, workspaceId, folderIds), isNull(config.deletedColumn)))
126+
.returning({ id: config.idColumn })
127+
).length
128+
120129
return { folders: archivedFolders.length, children }
121130
}
122131

apps/sim/lib/folders/lifecycle.ts

Lines changed: 26 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -411,9 +411,29 @@ export async function restoreFolder(params: RestoreFolderParams): Promise<Restor
411411
}
412412
}
413413

414-
let restored: { folderIds: string[]; counts: { folders: number; children: number } }
414+
const folderIds = await collectArchivedSubtreeIds(
415+
db,
416+
workspaceId,
417+
resourceType,
418+
folderId,
419+
archivedAt
420+
)
421+
422+
// Resources with a `restoreChildren` hook come back BEFORE the folder rows. Those hooks
423+
// call canonical restores that open their own transactions, so they cannot run inside the
424+
// one below — and doing them first is what keeps a partial failure recoverable. Restoring
425+
// folders first would clear the root's `deletedAt`, so a later child failure would
426+
// short-circuit every retry on the `!archivedAt` early return above, stranding whatever
427+
// had not come back yet. In this order a failure leaves the folder archived and the whole
428+
// restore simply retryable; children already restored no longer match the timestamp and
429+
// are skipped.
430+
const hookChildren = config.restoreChildren
431+
? await config.restoreChildren({ workspaceId, folderIds, timestamp: archivedAt })
432+
: null
433+
434+
let counts: { folders: number; children: number }
415435
try {
416-
restored = await db.transaction(async (tx) => {
436+
counts = await db.transaction(async (tx) => {
417437
const now = new Date()
418438

419439
let resolvedParentId = folder.parentId
@@ -455,25 +475,13 @@ export async function restoreFolder(params: RestoreFolderParams): Promise<Restor
455475
await tx.update(folderTable).set({ name: restoredName }).where(eq(folderTable.id, folderId))
456476
}
457477

458-
const folderIds = await collectArchivedSubtreeIds(
459-
tx,
460-
workspaceId,
461-
resourceType,
462-
folderId,
463-
archivedAt
464-
)
465-
466478
const folders = await restoreFolderRows(tx, config, workspaceId, folderIds, archivedAt, now)
467479

468-
// A resource with a `restoreChildren` hook restores through its own canonical path,
469-
// which opens its own transactions — running that nested inside this one would hold a
470-
// folder-row transaction open across unrelated connections. Those run after the commit
471-
// below instead; the default row-update path stays inside the transaction.
472-
const children = config.restoreChildren
473-
? 0
474-
: await restoreFolderChildren(tx, config, workspaceId, folderIds, archivedAt, now)
480+
const children =
481+
hookChildren ??
482+
(await restoreFolderChildren(tx, config, workspaceId, folderIds, archivedAt, now))
475483

476-
return { folderIds, counts: { folders, children } }
484+
return { folders, children }
477485
})
478486
} catch (error) {
479487
// Clearing `deletedAt` brings the row back under the partial unique index on active
@@ -485,17 +493,6 @@ export async function restoreFolder(params: RestoreFolderParams): Promise<Restor
485493
throw error
486494
}
487495

488-
const counts = config.restoreChildren
489-
? {
490-
folders: restored.counts.folders,
491-
children: await config.restoreChildren({
492-
workspaceId,
493-
folderIds: restored.folderIds,
494-
timestamp: archivedAt,
495-
}),
496-
}
497-
: restored.counts
498-
499496
logger.info('Restored folder and all contents', { folderId, resourceType, counts })
500497

501498
recordAudit({

0 commit comments

Comments
 (0)