Skip to content

Commit 0a66b67

Browse files
committed
fix(tables): make folder cascades retry-safe
1 parent 83e7a85 commit 0a66b67

7 files changed

Lines changed: 164 additions & 30 deletions

File tree

‎apps/sim/lib/folders/bulk.test.ts‎

Lines changed: 85 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,28 @@
33
*/
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
55

6-
const { mockListActiveFolderRows } = vi.hoisted(() => ({
7-
mockListActiveFolderRows: vi.fn(),
8-
}))
6+
const { mockDeleteFolder, mockListActiveFolderRows, mockNotifyFolderResourceChanged } = vi.hoisted(
7+
() => ({
8+
mockDeleteFolder: vi.fn(),
9+
mockListActiveFolderRows: vi.fn(),
10+
mockNotifyFolderResourceChanged: vi.fn(),
11+
})
12+
)
913

1014
vi.mock('@/lib/folders/queries', () => ({
1115
listActiveFolderRows: mockListActiveFolderRows,
1216
}))
1317

1418
vi.mock('@/lib/folders/orchestration', () => ({
15-
deleteFolder: vi.fn(),
19+
deleteFolder: mockDeleteFolder,
1620
updateFolder: vi.fn(),
1721
}))
1822

1923
vi.mock('@/lib/realtime/notify', () => ({
20-
notifyFolderResourceChanged: vi.fn(),
24+
notifyFolderResourceChanged: mockNotifyFolderResourceChanged,
2125
}))
2226

23-
import { planFolderSelection } from '@/lib/folders/bulk'
27+
import { bulkDeleteFolders, planFolderSelection } from '@/lib/folders/bulk'
2428

2529
/**
2630
* `a` holds `a1`, which holds `a1x`. `b` is a sibling with nothing inside it, so a plan can
@@ -94,3 +98,78 @@ describe('planFolderSelection', () => {
9498
expect(result.selected).toEqual([{ id: 'b', name: 'B' }])
9599
})
96100
})
101+
102+
describe('bulkDeleteFolders', () => {
103+
beforeEach(() => {
104+
vi.clearAllMocks()
105+
})
106+
107+
const deleteFolders = (folders: Array<{ id: string; name: string }>) =>
108+
bulkDeleteFolders({
109+
workspaceId: 'ws-1',
110+
resourceType: 'table',
111+
userId: 'user-1',
112+
folders,
113+
countKey: 'tables',
114+
referenceCheckCompleted: true,
115+
})
116+
117+
it('retries a target folder after a later referrer folder succeeds', async () => {
118+
mockDeleteFolder
119+
.mockResolvedValueOnce({
120+
success: false,
121+
error: 'Target is referenced by Referrer',
122+
errorCode: 'conflict',
123+
})
124+
.mockResolvedValueOnce({
125+
success: true,
126+
deletedItems: { folders: 1, tables: 1 },
127+
})
128+
.mockResolvedValueOnce({
129+
success: true,
130+
deletedItems: { folders: 1, tables: 1 },
131+
})
132+
133+
const result = await deleteFolders([
134+
{ id: 'target-folder', name: 'Target' },
135+
{ id: 'referrer-folder', name: 'Referrer' },
136+
])
137+
138+
expect(result).toEqual({
139+
succeeded: [
140+
{ id: 'referrer-folder', name: 'Referrer' },
141+
{ id: 'target-folder', name: 'Target' },
142+
],
143+
failed: [],
144+
folderCount: 2,
145+
resourceCount: 2,
146+
})
147+
expect(mockDeleteFolder.mock.calls.map(([params]) => params.folderId)).toEqual([
148+
'target-folder',
149+
'referrer-folder',
150+
'target-folder',
151+
])
152+
expect(mockNotifyFolderResourceChanged).toHaveBeenCalledOnce()
153+
})
154+
155+
it('stops when a reference cycle makes no progress', async () => {
156+
mockDeleteFolder.mockResolvedValue({
157+
success: false,
158+
error: 'Referenced by another selected folder',
159+
errorCode: 'conflict',
160+
})
161+
162+
const result = await deleteFolders([
163+
{ id: 'folder-a', name: 'A' },
164+
{ id: 'folder-b', name: 'B' },
165+
])
166+
167+
expect(result.succeeded).toEqual([])
168+
expect(result.failed).toEqual([
169+
{ id: 'folder-a', name: 'A', reason: 'Referenced by another selected folder' },
170+
{ id: 'folder-b', name: 'B', reason: 'Referenced by another selected folder' },
171+
])
172+
expect(mockDeleteFolder).toHaveBeenCalledTimes(2)
173+
expect(mockNotifyFolderResourceChanged).not.toHaveBeenCalled()
174+
})
175+
})

‎apps/sim/lib/folders/bulk.ts‎

Lines changed: 51 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,10 @@ export async function bulkMoveFolders(params: {
244244
* notification replaces a per-folder storm of identical invalidations, and it
245245
* fires from a `finally` so a batch cut short by an internal fault still
246246
* announces the folders it did archive.
247+
*
248+
* A reference conflict is retried only after another folder succeeds. This
249+
* resolves cross-folder dependency chains without relying on request order,
250+
* while a cycle makes no progress and terminates after one wave.
247251
*/
248252
export async function bulkDeleteFolders(params: {
249253
workspaceId: string
@@ -260,30 +264,55 @@ export async function bulkDeleteFolders(params: {
260264
let resourceCount = 0
261265

262266
try {
263-
for (const folder of params.folders) {
264-
const result = await deleteFolder(
265-
{
266-
resourceType: params.resourceType,
267-
folderId: folder.id,
268-
workspaceId: params.workspaceId,
269-
userId: params.userId,
270-
folderName: folder.name,
271-
},
272-
{
273-
projectAudit: false,
274-
notify: false,
275-
referenceCheckCompleted: params.referenceCheckCompleted,
267+
let pending = [...params.folders]
268+
while (pending.length > 0) {
269+
const retryable: BulkFolderAffected[] = []
270+
const retryReasons = new Map<string, string>()
271+
let completedInWave = 0
272+
273+
for (const folder of pending) {
274+
const result = await deleteFolder(
275+
{
276+
resourceType: params.resourceType,
277+
folderId: folder.id,
278+
workspaceId: params.workspaceId,
279+
userId: params.userId,
280+
folderName: folder.name,
281+
},
282+
{
283+
projectAudit: false,
284+
notify: false,
285+
referenceCheckCompleted: params.referenceCheckCompleted,
286+
}
287+
)
288+
if (result.success) {
289+
succeeded.push(folder)
290+
folderCount += result.deletedItems?.folders ?? 0
291+
resourceCount += result.deletedItems?.[params.countKey] ?? 0
292+
completedInWave += 1
293+
continue
276294
}
277-
)
278-
if (result.success) {
279-
succeeded.push(folder)
280-
folderCount += result.deletedItems?.folders ?? 0
281-
resourceCount += result.deletedItems?.[params.countKey] ?? 0
282-
continue
295+
if (result.errorCode === 'internal') {
296+
throw new Error(result.error ?? 'Failed to delete folder')
297+
}
298+
if (result.errorCode === 'conflict') {
299+
retryable.push(folder)
300+
retryReasons.set(folder.id, result.error ?? 'Failed to delete folder')
301+
continue
302+
}
303+
failed.push({ ...folder, reason: result.error ?? 'Failed to delete folder' })
304+
}
305+
306+
if (completedInWave === 0) {
307+
for (const folder of retryable) {
308+
failed.push({
309+
...folder,
310+
reason: retryReasons.get(folder.id) ?? 'Failed to delete folder',
311+
})
312+
}
313+
break
283314
}
284-
if (result.errorCode === 'internal')
285-
throw new Error(result.error ?? 'Failed to delete folder')
286-
failed.push({ ...folder, reason: result.error ?? 'Failed to delete folder' })
315+
pending = retryable
287316
}
288317
} finally {
289318
if (succeeded.length > 0) {

‎apps/sim/lib/folders/orchestration.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,15 @@ describe('deleteFolder', () => {
784784
error: 'Table deletion is locked',
785785
errorCode: 'locked',
786786
})
787+
const archiveTimestamp = mockArchiveFolderCascade.mock.calls[0][4]
788+
expect(mockRestoreFolderRows).toHaveBeenCalledWith(
789+
dbChainMock.db,
790+
resourceConfig.current,
791+
'ws-1',
792+
['folder-1'],
793+
archiveTimestamp,
794+
expect.any(Date)
795+
)
787796
})
788797

789798
it('hands the guard the whole resolved subtree, not just the root', async () => {

‎apps/sim/lib/folders/orchestration.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -863,6 +863,7 @@ async function deleteFolderWithoutTreeLock(
863863
} catch (error) {
864864
const classified = asOrchestrationError(error)
865865
if (classified?.code === 'locked' || classified?.code === 'conflict') {
866+
await restoreFolderRows(db, config, workspaceId, folderIds, timestamp, new Date())
866867
return { success: false, error: classified.message, errorCode: classified.code }
867868
}
868869
throw error

‎apps/sim/lib/table/reference-columns/availability.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export function areTableReferenceColumnsEnabled(): Promise<boolean> {
99
return isFeatureEnabled('table-reference-columns')
1010
}
1111

12-
/** Rejects mutations that introduce or reconfigure a Reference column. */
12+
/** Rejects operations that expose or mutate Reference-column behavior. */
1313
export async function assertTableReferenceColumnsEnabled(): Promise<void> {
1414
if (!(await areTableReferenceColumnsEnabled())) {
1515
throw new OrchestrationError('forbidden', TABLE_REFERENCE_COLUMNS_DISABLED_MESSAGE)

‎apps/sim/lib/table/service.test.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,11 @@ import {
7777
const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8'
7878

7979
describe('listActiveTableNames', () => {
80-
beforeEach(() => resetDbChainMock())
80+
beforeEach(() => {
81+
vi.clearAllMocks()
82+
resetDbChainMock()
83+
mocks.assertTableReferenceColumnsEnabled.mockResolvedValue(undefined)
84+
})
8185

8286
it('returns the name projection without loading table schemas', async () => {
8387
queueTableRows(schemaMock.userTableDefinitions, [{ id: 'table-1', name: 'Accounts' }])
@@ -102,6 +106,17 @@ describe('listActiveTableNames', () => {
102106

103107
it('skips the database when no table IDs are requested', async () => {
104108
await expect(listActiveTableNames(WORKSPACE_ID, [])).resolves.toEqual([])
109+
expect(mocks.assertTableReferenceColumnsEnabled).toHaveBeenCalledOnce()
110+
expect(dbChainMockFns.select).not.toHaveBeenCalled()
111+
})
112+
113+
it('rejects name lookups before querying when reference columns are disabled', async () => {
114+
mocks.assertTableReferenceColumnsEnabled.mockRejectedValueOnce({ code: 'forbidden' })
115+
116+
await expect(listActiveTableNames(WORKSPACE_ID, ['table-1'])).rejects.toMatchObject({
117+
code: 'forbidden',
118+
})
119+
105120
expect(dbChainMockFns.select).not.toHaveBeenCalled()
106121
})
107122
})

‎apps/sim/lib/table/service.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,7 @@ export async function listActiveTableNames(
384384
workspaceId: string,
385385
tableIds: readonly string[]
386386
): Promise<Array<Pick<TableDefinition, 'id' | 'name'>>> {
387+
await assertTableReferenceColumnsEnabled()
387388
if (tableIds.length === 0) return []
388389

389390
return db

0 commit comments

Comments
 (0)