Skip to content

Commit ab271d9

Browse files
committed
feat(files): page, search, look up, and roll up workspace files over the session API
The workspace files browser could only read a workspace's whole file list. These session endpoints give it bounded reads that share the v2 and Mothership implementation under the `files.list` operation: - GET /api/workspaces/[id]/files accepts folderId (`root` or an id), sortBy, sortOrder, limit (up to 500), and cursor. Any of them asks for one keyset page with `nextCursor`; with none, the response is unchanged. - GET /api/workspaces/[id]/files/search?q matches file names case-insensitively, within a folder and (by default) its subfolders. - POST /api/workspaces/[id]/files/lookup resolves capped batches of ids, storage keys, and references without listing the workspace. - GET /api/workspaces/[id]/files/folder-stats?parentId reports each child folder's live file count and bytes, its subtree rolled up from one grouped aggregate. `queryWorkspaceFilePage` now takes a folder id beside a folder path, and the browser's collection read attaches share state only for the files it returns.
1 parent d4b7c86 commit ab271d9

11 files changed

Lines changed: 1182 additions & 48 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { workspaceFileFolderStatsContract } from '@/lib/api/contracts/workspace-files'
2+
import {
3+
defineInternalJsonRoute,
4+
internalRateLimits,
5+
internalSessionAuth,
6+
} from '@/lib/api/server/routes'
7+
import { internalFileErrorPolicies } from '@/lib/workspace-files/api'
8+
import { getWorkspaceFileFolderStats } from '@/lib/workspace-files/application/list-workspace-files'
9+
import { fileOperations } from '@/lib/workspace-files/application/operations'
10+
11+
export const dynamic = 'force-dynamic'
12+
13+
/**
14+
* GET /api/workspaces/[id]/files/folder-stats — live file count and bytes under each child
15+
* folder of `parentId` (default the root), each folder's subtree rolled up.
16+
*/
17+
export const GET = defineInternalJsonRoute({
18+
contract: workspaceFileFolderStatsContract,
19+
auth: internalSessionAuth,
20+
operation: fileOperations.list,
21+
rateLimit: internalRateLimits.user({ bucketName: 'workspace-files.read' }),
22+
errorPolicy: internalFileErrorPolicies.default,
23+
mapInput: ({ params, query }) => ({ workspaceId: params.id, parentId: query.parentId }),
24+
useCase: getWorkspaceFileFolderStats,
25+
present: ({ folders }) => ({ success: true as const, folders }),
26+
})
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { lookupWorkspaceFilesContract } from '@/lib/api/contracts/workspace-files'
2+
import {
3+
defineInternalJsonRoute,
4+
internalRateLimits,
5+
internalSessionAuth,
6+
} from '@/lib/api/server/routes'
7+
import { internalFileErrorPolicies, internalFilePresenters } from '@/lib/workspace-files/api'
8+
import { lookupWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files'
9+
import { fileOperations } from '@/lib/workspace-files/application/operations'
10+
11+
export const dynamic = 'force-dynamic'
12+
13+
/**
14+
* POST /api/workspaces/[id]/files/lookup — resolve a bounded batch of live workspace files by
15+
* id, storage key, or reference without listing the workspace.
16+
*/
17+
export const POST = defineInternalJsonRoute({
18+
contract: lookupWorkspaceFilesContract,
19+
auth: internalSessionAuth,
20+
operation: fileOperations.list,
21+
rateLimit: internalRateLimits.user({ bucketName: 'workspace-files.read' }),
22+
errorPolicy: internalFileErrorPolicies.default,
23+
mapInput: ({ params, body }) => ({
24+
workspaceId: params.id,
25+
ids: body.ids,
26+
keys: body.keys,
27+
references: body.references,
28+
}),
29+
useCase: lookupWorkspaceFiles,
30+
present: ({ files, references }) => ({
31+
...internalFilePresenters.successFiles({ files }),
32+
references,
33+
}),
34+
})

‎apps/sim/app/api/workspaces/[id]/files/route.test.ts‎

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ vi.mock('@/lib/workspace-files/application/list-workspace-files', () => workspac
2828
vi.mock('@/lib/posthog/server', () => posthogServerMock)
2929

3030
import { OrchestrationError } from '@/lib/core/orchestration/types'
31-
import { POST } from '@/app/api/workspaces/[id]/files/route'
31+
import { GET, POST } from '@/app/api/workspaces/[id]/files/route'
3232

3333
const WORKSPACE_ID = 'workspace-1'
3434
const USER = { id: 'user-1', name: 'Test User', email: 'test@sim.ai' }
@@ -62,7 +62,6 @@ describe('/api/workspaces/[id]/files', () => {
6262
authMockFns.mockGetSession.mockResolvedValue({ user: USER, session: { id: 'session-1' } })
6363
mocks.admitCreate.mockResolvedValue(undefined)
6464
mocks.createFile.mockResolvedValue({ file: FILE })
65-
workspaceFilesListMockFns.mockListAllWorkspaceFiles.mockResolvedValue({ files: [FILE] })
6665
})
6766

6867
it('authorizes the asserted workspace before buffering the create body', async () => {
@@ -87,4 +86,46 @@ describe('/api/workspaces/[id]/files', () => {
8786
expect(mocks.admitCreate).toHaveBeenCalled()
8887
expect(mocks.createFile).not.toHaveBeenCalled()
8988
})
89+
90+
describe('GET', () => {
91+
const list = (query: string) =>
92+
GET(
93+
createMockRequest({ method: 'GET', url: `/api/workspaces/${WORKSPACE_ID}/files${query}` }),
94+
context
95+
)
96+
const collection = workspaceFilesListMockFns.mockListWorkspaceFileCollection
97+
98+
it('keeps the unpaged response, without a cursor, when no paging param is sent', async () => {
99+
collection.mockResolvedValueOnce({ files: [FILE], nextKeys: null })
100+
101+
const response = await list('?scope=active')
102+
103+
expect(response.status).toBe(200)
104+
const body = await response.json()
105+
expect(body).toEqual({ success: true, files: [expect.objectContaining({ id: FILE.id })] })
106+
expect(collection.mock.calls[0][0].input).toEqual({
107+
workspaceId: WORKSPACE_ID,
108+
scope: 'active',
109+
})
110+
})
111+
112+
it('resumes from its own cursor and refuses it under another folder', async () => {
113+
collection.mockResolvedValue({ files: [FILE], nextKeys: ['notes.md', FILE.id] })
114+
115+
const first = await (await list('?folderId=root&sortBy=name&limit=1')).json()
116+
expect(collection.mock.calls[0][0].input.page).toMatchObject({
117+
folderId: null,
118+
after: undefined,
119+
})
120+
expect(first.nextCursor).toEqual(expect.any(String))
121+
122+
const resumed = await list(`?folderId=root&sortBy=name&limit=1&cursor=${first.nextCursor}`)
123+
expect(resumed.status).toBe(200)
124+
expect(collection.mock.calls[1][0].input.page.after).toEqual(['notes.md', FILE.id])
125+
126+
const moved = await list(`?folderId=folder-2&sortBy=name&limit=1&cursor=${first.nextCursor}`)
127+
expect(moved.status).toBe(400)
128+
expect(collection).toHaveBeenCalledTimes(2)
129+
})
130+
})
90131
})

‎apps/sim/app/api/workspaces/[id]/files/route.ts‎

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import {
22
createWorkspaceFileContract,
33
listWorkspaceFilesContract,
4+
WORKSPACE_FILE_PAGE_PARAMS,
45
} from '@/lib/api/contracts/workspace-files'
6+
import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding'
57
import {
68
defineInternalJsonRoute,
79
internalRateLimits,
@@ -17,22 +19,73 @@ import {
1719
admitCreateWorkspaceFile,
1820
createWorkspaceFile,
1921
} from '@/lib/workspace-files/application/create-workspace-file'
20-
import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files'
22+
import { listWorkspaceFileCollection } from '@/lib/workspace-files/application/list-workspace-files'
2123
import { fileOperations } from '@/lib/workspace-files/application/operations'
2224
import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration'
25+
import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response'
26+
27+
const DEFAULT_WORKSPACE_FILE_PAGE_SIZE = 100
2328

2429
export const dynamic = 'force-dynamic'
2530

26-
/** GET /api/workspaces/[id]/files — List workspace files. */
31+
/** Every param that changes which files, in which order, a page walk returns. */
32+
function fileCursorFilters(
33+
workspaceId: string,
34+
query: { scope: 'active' | 'archived'; folderId?: string | null }
35+
) {
36+
return cursorScopeKey(cursorRoute(listWorkspaceFilesContract, { id: workspaceId }), {
37+
scope: query.scope,
38+
folderId: query.folderId === null ? 'root' : query.folderId,
39+
})
40+
}
41+
42+
/**
43+
* GET /api/workspaces/[id]/files — List workspace files: one bounded page with `nextCursor`
44+
* when any paging param is sent, else the whole scope exactly as before paging existed.
45+
*/
2746
export const GET = defineInternalJsonRoute({
2847
contract: listWorkspaceFilesContract,
2948
auth: internalSessionAuth,
3049
operation: fileOperations.list,
3150
rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal file-list behavior' }),
3251
errorPolicy: internalFileErrorPolicies.default,
33-
mapInput: ({ params, query }) => ({ workspaceId: params.id, scope: query.scope }),
34-
useCase: listAllWorkspaceFiles,
35-
present: internalFilePresenters.successFiles,
52+
mapInput: ({ params, query }) => {
53+
if (!WORKSPACE_FILE_PAGE_PARAMS.some((param) => query[param] !== undefined)) {
54+
return { workspaceId: params.id, scope: query.scope }
55+
}
56+
const sortBy = query.sortBy ?? 'uploadedAt'
57+
const sortOrder = query.sortOrder ?? 'asc'
58+
return {
59+
workspaceId: params.id,
60+
scope: query.scope,
61+
page: {
62+
folderId: query.folderId,
63+
sortBy,
64+
sortOrder,
65+
limit: query.limit ?? DEFAULT_WORKSPACE_FILE_PAGE_SIZE,
66+
after: readSortedCursor(
67+
query.cursor,
68+
sortBy,
69+
sortOrder,
70+
fileCursorFilters(params.id, query)
71+
),
72+
},
73+
}
74+
},
75+
useCase: listWorkspaceFileCollection,
76+
present: (result, { input }) => {
77+
const listed = internalFilePresenters.successFiles(result)
78+
if (!input.page) return listed
79+
return {
80+
...listed,
81+
nextCursor: writeSortedCursor(
82+
result.nextKeys,
83+
input.page.sortBy,
84+
input.page.sortOrder,
85+
fileCursorFilters(input.workspaceId, { scope: input.scope, folderId: input.page.folderId })
86+
),
87+
}
88+
},
3689
})
3790

3891
/** POST /api/workspaces/[id]/files — Create an authored workspace file. */
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { searchWorkspaceFilesContract } from '@/lib/api/contracts/workspace-files'
2+
import {
3+
defineInternalJsonRoute,
4+
internalRateLimits,
5+
internalSessionAuth,
6+
} from '@/lib/api/server/routes'
7+
import { internalFileErrorPolicies, internalFilePresenters } from '@/lib/workspace-files/api'
8+
import { listWorkspaceFileCollection } from '@/lib/workspace-files/application/list-workspace-files'
9+
import { fileOperations } from '@/lib/workspace-files/application/operations'
10+
11+
export const dynamic = 'force-dynamic'
12+
13+
/**
14+
* GET /api/workspaces/[id]/files/search — live files whose name contains `q`, by name, each
15+
* carrying its `folderPath`; within `folderId` (and, by default, its subfolders) when given.
16+
*/
17+
export const GET = defineInternalJsonRoute({
18+
contract: searchWorkspaceFilesContract,
19+
auth: internalSessionAuth,
20+
operation: fileOperations.list,
21+
rateLimit: internalRateLimits.user({ bucketName: 'workspace-files.read' }),
22+
errorPolicy: internalFileErrorPolicies.default,
23+
mapInput: ({ params, query }) => ({
24+
workspaceId: params.id,
25+
scope: 'active' as const,
26+
page: {
27+
search: query.q,
28+
folderId: query.folderId,
29+
recursive: query.recursive,
30+
sortBy: 'name' as const,
31+
sortOrder: 'asc' as const,
32+
limit: query.limit,
33+
},
34+
}),
35+
useCase: listWorkspaceFileCollection,
36+
present: internalFilePresenters.successFiles,
37+
})

0 commit comments

Comments
 (0)