Skip to content

Commit 0e40ca0

Browse files
fix(files): serialize folder resolution with uploads
1 parent 3df2837 commit 0e40ca0

8 files changed

Lines changed: 174 additions & 40 deletions

File tree

apps/sim/app/api/v2/files/route.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,7 @@ describe('POST /api/v2/files', () => {
332332
userId: 'user-1',
333333
name: 'untitled.md',
334334
contentType: 'text/markdown',
335-
folderId: null,
335+
folderPath: '/',
336336
content: Buffer.alloc(0),
337337
exactName: true,
338338
request,
@@ -367,7 +367,7 @@ describe('POST /api/v2/files', () => {
367367
workspaceId: WS,
368368
name: 'seed.bin',
369369
contentType: 'application/octet-stream',
370-
folderId: FOLDER_ID,
370+
folderPath: '/Fixtures',
371371
content: Buffer.from([1, 2, 3]),
372372
exactName: true,
373373
})

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

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -137,16 +137,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
137137
const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
138138
if (access) return v2WorkspaceAccessError(access)
139139

140-
const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'file')
141-
const folderId = resolveFolderPathId(folderIndex, folderPath ?? '/')
142-
if (folderId === undefined) return v2Error('NOT_FOUND', 'Folder not found')
143-
144140
const result = await performCreateWorkspaceFile({
145141
workspaceId,
146142
userId,
147143
name,
148144
contentType: contentType ?? getMimeTypeFromExtension(getFileExtension(name)),
149-
folderId,
145+
folderPath: folderPath ?? '/',
150146
content: Buffer.from(content, encoding),
151147
exactName: true,
152148
request,

apps/sim/app/api/v2/files/uploads/route.test.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
77
const {
88
mockCheckRateLimit,
99
mockResolveWorkspaceAccess,
10-
mockAssertFolder,
1110
mockCreateUploadSession,
11+
mockLoadActiveFolderPathIndex,
1212
} = vi.hoisted(() => ({
1313
mockCheckRateLimit: vi.fn(),
1414
mockResolveWorkspaceAccess: vi.fn(),
15-
mockAssertFolder: vi.fn(),
1615
mockCreateUploadSession: vi.fn(),
16+
mockLoadActiveFolderPathIndex: vi.fn(),
1717
}))
1818

1919
vi.mock('@/app/api/v1/middleware', () => ({
@@ -25,8 +25,8 @@ vi.mock('@/app/api/v2/lib/gate', () => ({
2525
v2ApiGateError: vi.fn().mockResolvedValue(null),
2626
}))
2727

28-
vi.mock('@/lib/uploads/contexts/workspace', () => ({
29-
assertWorkspaceFileFolderTarget: mockAssertFolder,
28+
vi.mock('@/lib/folders/queries', () => ({
29+
loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex,
3030
}))
3131

3232
vi.mock('@/lib/uploads/upload-session/service', () => ({
@@ -60,7 +60,11 @@ describe('POST /api/v2/files/uploads', () => {
6060
vi.clearAllMocks()
6161
mockCheckRateLimit.mockResolvedValue(RATE_LIMIT)
6262
mockResolveWorkspaceAccess.mockResolvedValue(null)
63-
mockAssertFolder.mockResolvedValue(null)
63+
mockLoadActiveFolderPathIndex.mockResolvedValue({
64+
rowById: new Map(),
65+
pathById: new Map(),
66+
idByPath: new Map([['/Reports', 'folder-reports']]),
67+
})
6468
mockCreateUploadSession.mockResolvedValue({
6569
id: 'upload-1',
6670
workspaceId: WORKSPACE_ID,
@@ -144,7 +148,7 @@ describe('POST /api/v2/files/uploads', () => {
144148
})
145149

146150
expect(response.status).toBe(403)
147-
expect(mockAssertFolder).not.toHaveBeenCalled()
151+
expect(mockLoadActiveFolderPathIndex).not.toHaveBeenCalled()
148152
expect(mockCreateUploadSession).not.toHaveBeenCalled()
149153
})
150154

@@ -161,4 +165,24 @@ describe('POST /api/v2/files/uploads', () => {
161165
expect.objectContaining({ purpose: 'workspace_file', fileSize: 0 })
162166
)
163167
})
168+
169+
it('resolves a canonical folder path while the upload session is created', async () => {
170+
const response = await request({
171+
workspaceId: WORKSPACE_ID,
172+
name: 'file.csv',
173+
contentType: 'text/csv',
174+
size: 10,
175+
folderPath: '/Reports',
176+
})
177+
178+
expect(response.status).toBe(201)
179+
expect(mockLoadActiveFolderPathIndex).toHaveBeenCalledWith(
180+
WORKSPACE_ID,
181+
'file',
182+
expect.any(Object)
183+
)
184+
expect(mockCreateUploadSession).toHaveBeenCalledWith(
185+
expect.objectContaining({ metadata: { folderId: 'folder-reports' } })
186+
)
187+
})
164188
})

apps/sim/app/api/v2/files/uploads/route.ts

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,10 @@ import type { NextRequest } from 'next/server'
44
import { v2CreateFileUploadContract } from '@/lib/api/contracts/v2/files'
55
import { parseRequest } from '@/lib/api/server'
66
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
7-
import { loadActiveFolderPathIndex } from '@/lib/folders/queries'
87
import { createUploadSession } from '@/lib/uploads/upload-session/service'
98
import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
109
import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils'
11-
import { resolveFolderPathId } from '@/app/api/v2/lib/folders'
10+
import { withResolvedFolderPathMutation } from '@/app/api/v2/lib/folders'
1211
import { v2ApiGateError } from '@/app/api/v2/lib/gate'
1312
import {
1413
v2CaughtOrchestrationError,
@@ -41,20 +40,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
4140
const { workspaceId, name, contentType, size, folderPath } = parsed.data.body
4241
const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
4342
if (access) return v2WorkspaceAccessError(access)
44-
const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'file')
45-
const normalizedFolderId = resolveFolderPathId(folderIndex, folderPath ?? '/')
46-
if (normalizedFolderId === undefined) return v2Error('NOT_FOUND', 'Folder not found')
47-
48-
const session = await createUploadSession({
43+
const mutation = await withResolvedFolderPathMutation({
4944
workspaceId,
50-
userId,
51-
purpose: 'workspace_file',
52-
fileName: name,
53-
contentType,
54-
fileSize: size,
55-
metadata: { folderId: normalizedFolderId },
56-
localOrigin: request.nextUrl.origin,
45+
resourceType: 'file',
46+
path: folderPath ?? '/',
47+
mutate: (folderId) =>
48+
createUploadSession({
49+
workspaceId,
50+
userId,
51+
purpose: 'workspace_file',
52+
fileName: name,
53+
contentType,
54+
fileSize: size,
55+
metadata: { folderId },
56+
localOrigin: request.nextUrl.origin,
57+
}),
5758
})
59+
if (!mutation.found) return v2Error('NOT_FOUND', 'Folder not found')
60+
const session = mutation.value
5861
return v2Data(
5962
{
6063
session: toV2FileUpload(session, null),

apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -345,10 +345,26 @@ export async function resolveWorkspaceFileFolderTarget(
345345

346346
export async function assertWorkspaceFileFolderTarget(
347347
workspaceId: string,
348-
folderId?: string | null
348+
folderId?: string | null,
349+
executor: DbOrTx = db
349350
): Promise<string | null> {
350-
const folder = await resolveWorkspaceFileFolderTarget(workspaceId, folderId)
351-
return folder?.id ?? null
351+
const normalized = normalizeParentId(folderId)
352+
if (!normalized) return null
353+
354+
const [folder] = await executor
355+
.select({ id: folderTable.id })
356+
.from(folderTable)
357+
.where(
358+
and(
359+
eq(folderTable.id, normalized),
360+
eq(folderTable.workspaceId, workspaceId),
361+
isFileFolder,
362+
isNull(folderTable.deletedAt)
363+
)
364+
)
365+
.limit(1)
366+
if (!folder) throw new OrchestrationError('not_found', 'Target folder not found')
367+
return folder.id
352368
}
353369

354370
export async function createWorkspaceFileFolder(params: {

apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestrati
3838
import { generateRequestId } from '@/lib/core/utils/request'
3939
import { generateRestoreName } from '@/lib/core/utils/restore-name'
4040
import type { DbOrTx } from '@/lib/db/types'
41+
import { acquireFolderMutationLock } from '@/lib/folders/locks'
42+
import { parseFolderPath } from '@/lib/folders/paths'
43+
import { loadActiveFolderPathIndex, resolveFolderPathFromIndex } from '@/lib/folders/queries'
4144
import { mergeEditIntoLiveFileDoc, notifyWorkspaceFilesChanged } from '@/lib/realtime/notify'
4245
import { getServePathPrefix } from '@/lib/uploads'
4346
import {
@@ -338,13 +341,30 @@ export async function uploadWorkspaceFile(
338341
fileBuffer: Buffer,
339342
fileName: string,
340343
contentType: string,
341-
options?: { folderId?: string | null; exactName?: boolean }
344+
options?: { folderId?: string | null; folderPath?: string; exactName?: boolean }
342345
): Promise<UploadedWorkspaceFileRecord> {
343346
logger.info(`Uploading workspace file: ${fileName} for workspace ${workspaceId}`)
344347

345-
const folderTarget = await resolveWorkspaceFileFolderTarget(workspaceId, options?.folderId)
346-
const folderId = folderTarget?.id ?? null
347-
const folderPath = folderTarget?.path ?? null
348+
if (options?.folderId !== undefined && options.folderPath !== undefined) {
349+
throw new OrchestrationError('validation', 'Specify either folderId or folderPath, not both')
350+
}
351+
352+
let folderId: string | null
353+
let folderPath: string | null
354+
if (options?.folderPath !== undefined) {
355+
const folderPathSegments = parseFolderPath(options.folderPath)
356+
const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'file')
357+
const resolvedFolderId = resolveFolderPathFromIndex(folderIndex, options.folderPath)
358+
if (resolvedFolderId === undefined) {
359+
throw new OrchestrationError('not_found', 'Target folder not found')
360+
}
361+
folderId = resolvedFolderId
362+
folderPath = resolvedFolderId ? folderPathSegments.join('/') : null
363+
} else {
364+
const folderTarget = await resolveWorkspaceFileFolderTarget(workspaceId, options?.folderId)
365+
folderId = folderTarget?.id ?? null
366+
folderPath = folderTarget?.path ?? null
367+
}
348368
const normalizedFileName = normalizeWorkspaceFileItemName(fileName, 'File')
349369
const exactName = options?.exactName ?? false
350370
const storageBillingContext = await resolveStorageBillingContext(workspaceId)
@@ -370,7 +390,7 @@ export async function uploadWorkspaceFile(
370390
purpose: 'workspace',
371391
userId: userId,
372392
workspaceId: workspaceId,
373-
...(folderId ? { folderId } : {}),
393+
...(folderId && options?.folderPath === undefined ? { folderId } : {}),
374394
}
375395

376396
const uploadResult = await uploadFile({
@@ -392,12 +412,24 @@ export async function uploadWorkspaceFile(
392412
}
393413
try {
394414
finalized = await db.transaction(async (tx) => {
415+
await acquireFolderMutationLock(tx, workspaceId, 'file')
416+
let activeFolderId: string | null
417+
if (options?.folderPath !== undefined) {
418+
const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'file', tx)
419+
const resolvedFolderId = resolveFolderPathFromIndex(folderIndex, options.folderPath)
420+
if (resolvedFolderId === undefined) {
421+
throw new OrchestrationError('not_found', 'Target folder not found')
422+
}
423+
activeFolderId = resolvedFolderId
424+
} else {
425+
activeFolderId = await assertWorkspaceFileFolderTarget(workspaceId, folderId, tx)
426+
}
395427
const inserted = await insertWorkspaceFileMetadataInTx(tx, {
396428
id: fileId,
397429
key: uploadResult.key,
398430
userId,
399431
workspaceId,
400-
folderId,
432+
folderId: activeFolderId,
401433
originalName: uniqueName,
402434
contentType,
403435
size: fileBuffer.length,
@@ -537,7 +569,7 @@ export async function registerUploadedWorkspaceFile(params: {
537569
}
538570
}
539571

540-
const folderId = await assertWorkspaceFileFolderTarget(workspaceId, params.folderId)
572+
const folderId = params.folderId ?? null
541573

542574
const storageBillingContext = await resolveStorageBillingContext(workspaceId)
543575
for (let attempt = 0; attempt < MAX_UPLOAD_UNIQUE_RETRIES; attempt++) {
@@ -549,12 +581,14 @@ export async function registerUploadedWorkspaceFile(params: {
549581
)
550582

551583
const finalized = await db.transaction(async (tx) => {
584+
await acquireFolderMutationLock(tx, workspaceId, 'file')
585+
const activeFolderId = await assertWorkspaceFileFolderTarget(workspaceId, folderId, tx)
552586
const inserted = await insertWorkspaceFileMetadataInTx(tx, {
553587
id: fileId,
554588
key,
555589
userId,
556590
workspaceId,
557-
folderId,
591+
folderId: activeFolderId,
558592
originalName: displayName,
559593
contentType,
560594
size: verifiedSize,

0 commit comments

Comments
 (0)