diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index 8b187a34d51..d6e05660d9e 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -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 * diff --git a/apps/sim/lib/folders/queries.ts b/apps/sim/lib/folders/queries.ts index 39863653795..6bc402b68aa 100644 --- a/apps/sim/lib/folders/queries.ts +++ b/apps/sim/lib/folders/queries.ts @@ -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], diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts index 0e658b59f59..b71a04f0111 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts @@ -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' @@ -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' @@ -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 { - const { scope = 'active' } = options ?? {} + const { scope = 'active', sortBy = 'position', sortOrder = 'asc' } = options ?? {} const rows = await db .select() .from(folderTable) @@ -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)) diff --git a/apps/sim/lib/workspace-files/application/workspace-file-folders.test.ts b/apps/sim/lib/workspace-files/application/workspace-file-folders.test.ts index bec03b034a7..5f9337d19e1 100644 --- a/apps/sim/lib/workspace-files/application/workspace-file-folders.test.ts +++ b/apps/sim/lib/workspace-files/application/workspace-file-folders.test.ts @@ -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' }, diff --git a/apps/sim/lib/workspace-files/application/workspace-file-folders.ts b/apps/sim/lib/workspace-files/application/workspace-file-folders.ts index baa2e85d5c3..9667b044589 100644 --- a/apps/sim/lib/workspace-files/application/workspace-file-folders.ts +++ b/apps/sim/lib/workspace-files/application/workspace-file-folders.ts @@ -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, @@ -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 + sortOrder?: ListSortOrder } export interface ListWorkspaceFileFoldersResult { @@ -115,6 +123,8 @@ async function executeListWorkspaceFileFolders(args: { }): Promise { 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) @@ -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 } }