Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/sim/lib/api/contracts/v2/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,9 @@ import { FolderPathError, parseFolderPath, requireNonRootFolderPath } from '@/li
*
* Every one of these is pushed into SQL, except on `GET /skills` (which merges
* the static builtin registry into the DB rows, then re-filters and re-sorts the
* merged array) and `GET /files/folders` (which applies `parentPath`, `search`,
* and the sort in JS). Both read a full result set to produce a page; neither is
* a pattern to copy.
* merged array) and `GET /files/folders` (which applies `parentPath` and `search`
* in JS; its sort is pushed into SQL like every other folder list). Both read a
* full result set to produce a page; neither is a pattern to copy.
*
* ## Which lists are paged
*
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/folders/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ export async function resolveRestoredFolderId(
* enum by `satisfies`. Each ends in `createdAt` so folders sharing a name or a
* `sortOrder` still come back in a stable order.
*/
const FOLDER_SORTS = {
export const FOLDER_SORTS = {
position: [folder.sortOrder, folder.createdAt],
name: [folder.name, folder.createdAt],
createdAt: [folder.createdAt],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { folder as folderTable, workspaceFiles, workspace as workspaceTable } fr
import { createLogger } from '@sim/logger'
import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, asc, eq, inArray, isNull, min, sql } from 'drizzle-orm'
import { and, eq, inArray, isNull, min, sql } from 'drizzle-orm'
import { type ListSortOrder, listOrderBy } from '@/lib/api/list-query'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import type { DbOrTx } from '@/lib/db/types'
import { acquireFolderMutationLock } from '@/lib/folders/locks'
Expand All @@ -17,6 +18,7 @@ import {
parseFolderPath,
requireNonRootFolderPath,
} from '@/lib/folders/paths'
import { FOLDER_SORTS, type FolderSortBy } from '@/lib/folders/queries'
import { collectDescendantFolderIds } from '@/lib/folders/subtree'
import { encodeWorkspaceFileFolderDisplaySegment } from '@/lib/workspace-files/folder-display-path'
import { MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS } from '@/lib/workspace-files/limits'
Expand Down Expand Up @@ -383,11 +385,21 @@ export async function findWorkspaceFileFolderIdByPath(
return parentId
}

/**
* Lists a workspace's file folders, ordered in the database like every other folder
* list so a name sort uses the same collation and the same `createdAt` tiebreak.
* Defaults to `position` — `sortOrder ASC, createdAt ASC` — which honours a user's
* manual ordering and is what surfaces reading the payload positionally expect.
*/
export async function listWorkspaceFileFolders(
workspaceId: string,
options?: { scope?: WorkspaceFileFolderScope }
options?: {
scope?: WorkspaceFileFolderScope
sortBy?: FolderSortBy
sortOrder?: ListSortOrder
}
): Promise<WorkspaceFileFolderRecord[]> {
const { scope = 'active' } = options ?? {}
const { scope = 'active', sortBy = 'position', sortOrder = 'asc' } = options ?? {}
const rows = await db
.select()
.from(folderTable)
Expand All @@ -406,7 +418,7 @@ export async function listWorkspaceFileFolders(
isNull(folderTable.deletedAt)
)
)
.orderBy(asc(folderTable.sortOrder), asc(folderTable.createdAt))
.orderBy(...listOrderBy(FOLDER_SORTS[sortBy], sortOrder))

const paths = buildWorkspaceFileFolderPathMap(rows)
return rows.map((row) => mapFolder(row, paths))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,38 @@ describe('workspace file folder operations', () => {
expect(mockNotify).toHaveBeenCalledOnce()
})

it.each([
['leaves the sort unset so the repository keeps its position ordering', {}, undefined],
['delegates an explicit sort', { sortBy: 'name', sortOrder: 'desc' } as const, 'name'],
])('%s', async (_label, sortInput, expectedSortBy) => {
mockList.mockResolvedValue([folder])

await listWorkspaceFileFoldersOperation.execute({
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
input: { workspaceId: 'ws-1', ...sortInput },
})

expect(mockList).toHaveBeenCalledWith(
'ws-1',
expect.objectContaining({ sortBy: expectedSortBy })
)
})

it('preserves the order the repository returned rather than re-sorting in memory', async () => {
mockList.mockResolvedValue([
{ ...folder, id: 'newest', name: 'zeta' },
{ ...folder, id: 'middle', name: 'Alpha' },
{ ...folder, id: 'oldest', name: 'beta' },
])

const result = await listWorkspaceFileFoldersOperation.execute({
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
input: { workspaceId: 'ws-1' },
})

expect(result.folders.map((item) => item.id)).toEqual(['newest', 'middle', 'oldest'])
})

it('matches a canonical encoded parent path against decoded stored folder paths', async () => {
mockList.mockResolvedValue([
{ ...folder, id: 'child-1', name: 'Q1', path: 'Reports & Plans/Q1' },
Expand Down
22 changes: 12 additions & 10 deletions apps/sim/lib/workspace-files/application/workspace-file-folders.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { AuditAction, AuditResourceType } from '@sim/audit'
import { resolvePrincipalAttribution } from '@sim/auth/principal'
import { createLogger } from '@sim/logger'
import type { ListSortOrder } from '@/lib/api/list-query'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { parseFolderPath } from '@/lib/folders/paths'
import type { FolderSortBy } from '@/lib/folders/queries'
import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify'
import {
assertWorkspaceFileItemsBelongToWorkspace,
Expand Down Expand Up @@ -30,8 +32,14 @@ export interface ListWorkspaceFileFoldersInput {
scope?: 'active' | 'archived' | 'all'
parentPath?: string
search?: string
sortBy?: 'name' | 'createdAt' | 'updatedAt'
sortOrder?: 'asc' | 'desc'
/**
* Only v2 sends a sort; the internal route, Copilot, and the VFS do not, and some of
* their consumers render the payload in the order it arrives. So this stays optional
* and undefined means "leave the repository's `position` ordering alone" — a default
* applied here would silently reorder those surfaces.
*/
sortBy?: Exclude<FolderSortBy, 'position'>
sortOrder?: ListSortOrder
}

export interface ListWorkspaceFileFoldersResult {
Expand Down Expand Up @@ -115,6 +123,8 @@ async function executeListWorkspaceFileFolders(args: {
}): Promise<ListWorkspaceFileFoldersResult> {
let folders = await listWorkspaceFileFolders(args.context.workspaceId, {
scope: args.input.scope,
sortBy: args.input.sortBy,
sortOrder: args.input.sortOrder,
})
if (args.input.parentPath !== undefined) {
const parentSegments = parseFolderPath(args.input.parentPath)
Expand All @@ -128,14 +138,6 @@ async function executeListWorkspaceFileFolders(args: {
const search = args.input.search.toLowerCase()
folders = folders.filter((folder) => folder.name.toLowerCase().includes(search))
}
const sortBy = args.input.sortBy ?? 'name'
const sortOrder = args.input.sortOrder ?? 'asc'
folders.sort((left, right) => {
const leftValue = left[sortBy]
const rightValue = right[sortBy]
const comparison = leftValue < rightValue ? -1 : leftValue > rightValue ? 1 : 0
return sortOrder === 'asc' ? comparison : -comparison
})
return { folders }
}

Expand Down
Loading