Skip to content

Commit ab11711

Browse files
fix(api): normalize folder paths and unblock resource mutations
1 parent 2a27f9e commit ab11711

17 files changed

Lines changed: 361 additions & 233 deletions

File tree

apps/sim/app/api/v2/knowledge/[id]/route.ts

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import {
99
import { parseRequest } from '@/lib/api/server'
1010
import { generateRequestId } from '@/lib/core/utils/request'
1111
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
12-
import { withFolderTreeLock } from '@/lib/folders/locks'
1312
import { loadActiveFolderPathIndex } from '@/lib/folders/queries'
1413
import {
1514
performDeleteKnowledgeBase,
@@ -18,7 +17,7 @@ import {
1817
import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types'
1918
import { formatKnowledgeBase, resolveKnowledgeBase } from '@/app/api/v1/knowledge/utils'
2019
import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware'
21-
import { folderPathForId, resolveFolderPathId } from '@/app/api/v2/lib/folders'
20+
import { folderPathForId, resolveFolderPathIdentity } from '@/app/api/v2/lib/folders'
2221
import { v2ApiGateError } from '@/app/api/v2/lib/gate'
2322
import {
2423
v2Data,
@@ -134,30 +133,32 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: Knowle
134133
const result = await resolveKnowledgeBaseScoped(id, workspaceId, userId, rateLimit, 'write')
135134
if (result instanceof NextResponse) return result
136135

137-
const mutation = await withFolderTreeLock(workspaceId, 'knowledge_base', async (tx) => {
138-
const index = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base', tx)
139-
const folderId = folderPath === undefined ? undefined : resolveFolderPathId(index, folderPath)
140-
if (folderPath !== undefined && folderId === undefined) return { found: false as const }
141-
142-
const outcome = await performUpdateKnowledgeBase({
143-
knowledgeBaseId: id,
144-
workspaceId,
145-
userId,
146-
source: 'api',
147-
updates: { name, description, chunkingConfig, folderId },
148-
requestId,
149-
request,
150-
})
151-
return { found: true as const, index, outcome }
152-
})
153-
if (!mutation.found) {
136+
const resolution =
137+
folderPath === undefined
138+
? undefined
139+
: await resolveFolderPathIdentity({
140+
workspaceId,
141+
resourceType: 'knowledge_base',
142+
path: folderPath,
143+
})
144+
if (resolution && !resolution.found) {
154145
return v2Error('NOT_FOUND', 'Folder not found')
155146
}
156-
const { index: folderIndex, outcome } = mutation
147+
148+
const outcome = await performUpdateKnowledgeBase({
149+
knowledgeBaseId: id,
150+
workspaceId,
151+
userId,
152+
source: 'api',
153+
updates: { name, description, chunkingConfig, folderId: resolution?.folderId },
154+
requestId,
155+
request,
156+
})
157157
if (!outcome.success) {
158158
return v2ErrorForOrchestration(outcome.errorCode, outcome.error)
159159
}
160160

161+
const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base')
161162
return v2Data(
162163
{
163164
knowledgeBase: {

apps/sim/app/api/v2/knowledge/route.ts

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
1616
import {
1717
folderPathForId,
1818
resolveFolderPathId,
19-
withResolvedFolderPathMutation,
19+
resolveFolderPathIdentity,
2020
} from '@/app/api/v2/lib/folders'
2121
import { v2ApiGateError } from '@/app/api/v2/lib/gate'
2222
import {
@@ -118,25 +118,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
118118
const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
119119
if (access) return v2WorkspaceAccessError(access)
120120

121-
const mutation = await withResolvedFolderPathMutation({
121+
const resolution = await resolveFolderPathIdentity({
122122
workspaceId,
123123
resourceType: 'knowledge_base',
124124
path: folderPath ?? '/',
125-
mutate: (folderId) =>
126-
performCreateKnowledgeBase({
127-
userId,
128-
source: 'api',
129-
workspaceId,
130-
name,
131-
description,
132-
chunkingConfig,
133-
folderId,
134-
requestId,
135-
request,
136-
}),
137125
})
138-
if (!mutation.found) return v2Error('NOT_FOUND', 'Folder not found')
139-
const outcome = mutation.value
126+
if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found')
127+
128+
const outcome = await performCreateKnowledgeBase({
129+
userId,
130+
source: 'api',
131+
workspaceId,
132+
name,
133+
description,
134+
chunkingConfig,
135+
folderId: resolution.folderId,
136+
requestId,
137+
request,
138+
})
140139
if (!outcome.success) {
141140
return v2ErrorForOrchestration(outcome.errorCode, outcome.error)
142141
}
@@ -145,7 +144,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
145144
{
146145
knowledgeBase: {
147146
...formatKnowledgeBase(outcome.knowledgeBase),
148-
folderPath: folderPathForId(mutation.index, outcome.knowledgeBase.folderId),
147+
folderPath: folderPathForId(resolution.index, outcome.knowledgeBase.folderId),
149148
},
150149
},
151150
{ rateLimit, status: 201 }

apps/sim/app/api/v2/lib/folders.ts

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,11 @@ export function resolveFolderPathId(
2121
return path === ROOT_FOLDER_PATH ? null : index.idByPath.get(path)
2222
}
2323

24-
export type ResolvedFolderPathMutation<T> =
24+
export type ResolvedFolderPathIdentity =
2525
| { found: false }
26-
| { found: true; folderId: string | null; index: FolderPathIndex<FolderRow>; value: T }
26+
| { found: true; folderId: string | null; index: FolderPathIndex<FolderRow> }
2727

28-
export type ResolvedFolderPathIdentity = { found: false } | { found: true; folderId: string | null }
29-
30-
/** Resolves a canonical path to its stable internal identity under the folder tree lock. */
28+
/** Resolves a path to its stable internal identity under a short-lived folder tree lock. */
3129
export async function resolveFolderPathIdentity(params: {
3230
workspaceId: string
3331
resourceType: FolderResourceType
@@ -36,23 +34,7 @@ export async function resolveFolderPathIdentity(params: {
3634
return withFolderTreeLock(params.workspaceId, params.resourceType, async (tx) => {
3735
const index = await loadActiveFolderPathIndex(params.workspaceId, params.resourceType, tx)
3836
const folderId = resolveFolderPathId(index, params.path)
39-
return folderId === undefined ? { found: false } : { found: true, folderId }
40-
})
41-
}
42-
43-
/** Resolves a canonical path and keeps that folder tree stable through a resource mutation. */
44-
export async function withResolvedFolderPathMutation<T>(params: {
45-
workspaceId: string
46-
resourceType: FolderResourceType
47-
path: string
48-
mutate: (folderId: string | null) => Promise<T>
49-
}): Promise<ResolvedFolderPathMutation<T>> {
50-
return withFolderTreeLock(params.workspaceId, params.resourceType, async (tx) => {
51-
const index = await loadActiveFolderPathIndex(params.workspaceId, params.resourceType, tx)
52-
const folderId = resolveFolderPathId(index, params.path)
53-
if (folderId === undefined) return { found: false }
54-
const value = await params.mutate(folderId)
55-
return { found: true, folderId, index, value }
37+
return folderId === undefined ? { found: false } : { found: true, folderId, index }
5638
})
5739
}
5840

apps/sim/app/api/v2/tables/[tableId]/route.ts

Lines changed: 54 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import { parseRequest } from '@/lib/api/server'
1010
import { asOrchestrationError } from '@/lib/core/orchestration/types'
1111
import { generateRequestId } from '@/lib/core/utils/request'
1212
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
13-
import { withFolderTreeLock } from '@/lib/folders/locks'
1413
import { loadActiveFolderPathIndex } from '@/lib/folders/queries'
1514
import { getTableById } from '@/lib/table'
1615
import { signalTableSchemaChanged } from '@/lib/table/events'
@@ -21,7 +20,7 @@ import {
2120
} from '@/lib/table/orchestration'
2221
import { checkAccess } from '@/app/api/table/utils'
2322
import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware'
24-
import { folderPathForId, resolveFolderPathId } from '@/app/api/v2/lib/folders'
23+
import { folderPathForId, resolveFolderPathIdentity } from '@/app/api/v2/lib/folders'
2524
import { v2ApiGateError } from '@/app/api/v2/lib/gate'
2625
import {
2726
v2Data,
@@ -155,75 +154,66 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl
155154
return v2Error('NOT_FOUND', 'Table not found')
156155
}
157156

158-
return await withFolderTreeLock(table.workspaceId, 'table', async (tx) => {
159-
const folderIndex = await loadActiveFolderPathIndex(table.workspaceId, 'table', tx)
160-
const folderId =
161-
validated.folderPath === undefined
162-
? undefined
163-
: resolveFolderPathId(folderIndex, validated.folderPath)
164-
if (validated.folderPath !== undefined && folderId === undefined) {
165-
return v2Error('NOT_FOUND', 'Folder not found in this workspace')
166-
}
157+
const resolution =
158+
validated.folderPath === undefined
159+
? undefined
160+
: await resolveFolderPathIdentity({
161+
workspaceId: table.workspaceId,
162+
resourceType: 'table',
163+
path: validated.folderPath,
164+
})
165+
if (resolution && !resolution.found) {
166+
return v2Error('NOT_FOUND', 'Folder not found in this workspace')
167+
}
167168

168-
// Rename and move retain their shared services' independent transactions and audits.
169-
// Validate deterministic failures first and report `applied` so callers can reconcile
170-
// if a later fault lands after an earlier operation commits.
171-
let failure: { outcome: OrchestrationOutcome; fallback: string } | null = null
172-
173-
if (validated.name !== undefined) {
174-
const outcome = await performRenameTable({
175-
table,
176-
newName: validated.name,
177-
userId,
178-
requestId,
179-
request,
180-
})
181-
if (outcome.success) applied.push('name')
182-
else failure = { outcome, fallback: 'Failed to rename table' }
183-
}
169+
let failure: { outcome: OrchestrationOutcome; fallback: string } | null = null
170+
171+
if (validated.name !== undefined) {
172+
const outcome = await performRenameTable({
173+
table,
174+
newName: validated.name,
175+
userId,
176+
requestId,
177+
request,
178+
})
179+
if (outcome.success) applied.push('name')
180+
else failure = { outcome, fallback: 'Failed to rename table' }
181+
}
184182

185-
if (!failure && validated.folderPath !== undefined) {
186-
const outcome = await performMoveTableToFolder({
187-
table,
188-
folderId: folderId ?? null,
189-
userId,
190-
requestId,
191-
request,
192-
})
193-
if (outcome.success) {
194-
applied.push('folderPath')
195-
} else {
196-
// The move re-asserts workspace and active state, so a miss means the
197-
// table was archived between `checkAccess` and the write.
198-
failure = {
199-
outcome:
200-
outcome.errorCode === 'not_found'
201-
? { ...outcome, error: 'Table not found' }
202-
: outcome,
203-
fallback: 'Failed to move table',
204-
}
183+
if (!failure && validated.folderPath !== undefined) {
184+
const outcome = await performMoveTableToFolder({
185+
table,
186+
folderId: resolution?.folderId ?? null,
187+
userId,
188+
requestId,
189+
request,
190+
})
191+
if (outcome.success) {
192+
applied.push('folderPath')
193+
} else {
194+
failure = {
195+
outcome:
196+
outcome.errorCode === 'not_found' ? { ...outcome, error: 'Table not found' } : outcome,
197+
fallback: 'Failed to move table',
205198
}
206199
}
200+
}
207201

208-
// Live-collab: tell open viewers the definition changed so they refetch.
209-
if (applied.length > 0) signalTableSchemaChanged(tableId)
210-
if (failure) {
211-
return v2TableOrchestrationError(failure.outcome, failure.fallback, appliedDetails(applied))
212-
}
202+
if (applied.length > 0) signalTableSchemaChanged(tableId)
203+
if (failure) {
204+
return v2TableOrchestrationError(failure.outcome, failure.fallback, appliedDetails(applied))
205+
}
213206

214-
// Re-read so the response reflects every applied change at once. A miss
215-
// means the table was archived after the writes committed, so the caller
216-
// still has to be told what landed.
217-
const updated = await getTableById(tableId)
218-
if (!updated) {
219-
return v2Error('NOT_FOUND', 'Table not found', { details: appliedDetails(applied) })
220-
}
207+
const updated = await getTableById(tableId)
208+
if (!updated) {
209+
return v2Error('NOT_FOUND', 'Table not found', { details: appliedDetails(applied) })
210+
}
221211

222-
return v2Data(
223-
{ table: toApiTable(updated, folderPathForId(folderIndex, updated.folderId)) },
224-
{ rateLimit }
225-
)
226-
})
212+
const folderIndex = await loadActiveFolderPathIndex(table.workspaceId, 'table')
213+
return v2Data(
214+
{ table: toApiTable(updated, folderPathForId(folderIndex, updated.folderId)) },
215+
{ rateLimit }
216+
)
227217
} catch (error) {
228218
const details = appliedDetails(applied)
229219

apps/sim/app/api/v2/tables/imports/route.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
toV2CreateTableImport,
1010
} from '@/lib/table/orchestration/import-resource'
1111
import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware'
12-
import { withResolvedFolderPathMutation } from '@/app/api/v2/lib/folders'
12+
import { resolveFolderPathIdentity } from '@/app/api/v2/lib/folders'
1313
import { v2ApiGateError } from '@/app/api/v2/lib/gate'
1414
import {
1515
v2CaughtOrchestrationError,
@@ -43,15 +43,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
4343
if (scopeError) return v2WorkspaceAccessError(scopeError)
4444
let created: Awaited<ReturnType<typeof createTableImportResource>>
4545
if (parsed.data.body.target.type === 'new') {
46-
const mutation = await withResolvedFolderPathMutation({
46+
const resolution = await resolveFolderPathIdentity({
4747
workspaceId: parsed.data.body.workspaceId,
4848
resourceType: 'table',
4949
path: parsed.data.body.target.folderPath ?? '/',
50-
mutate: (folderId) =>
51-
createTableImportResource(parsed.data.body, userId, request.nextUrl.origin, folderId),
5250
})
53-
if (!mutation.found) return v2Error('NOT_FOUND', 'Folder not found')
54-
created = mutation.value
51+
if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found')
52+
created = await createTableImportResource(
53+
parsed.data.body,
54+
userId,
55+
request.nextUrl.origin,
56+
resolution.folderId
57+
)
5558
} else {
5659
created = await createTableImportResource(parsed.data.body, userId, request.nextUrl.origin)
5760
}

0 commit comments

Comments
 (0)