fix(files): order file folders in SQL like every other folder list (#6599)

The list use case defaulted to a name sort whose defaults were written for the
new v2 contract, so the internal route — whose contract exposes no sort params —
could no longer reach the repository's sortOrder ASC, createdAt ASC ordering.
Surfaces that render the payload positionally (the @-mention Folders group, the
add-resource search results, Copilot's list_file_folders) silently flipped from
newest-first to alphabetical, and the Files browser's SSR prefetch hydrated a
different order than its own refetch.

Push the sort down to the query like the workflow, knowledge, and table folder
lists already do, reusing FOLDER_SORTS. Omitting sortBy keeps the position
ordering; v2 always sends one from its contract defaults. A name sort now also
uses the database collation and the shared createdAt tiebreak instead of a JS
comparator over UTF-16 code units.
This commit is contained in:
Waleed
2026-08-11 21:48:01 -07:00
committed by GitHub
parent 933eea50df
commit 22b3569627
5 changed files with 64 additions and 18 deletions
+3 -3
View File
@@ -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
*
+1 -1
View File
@@ -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],
@@ -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<WorkspaceFileFolderRecord[]> {
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))
@@ -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' },
@@ -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<FolderSortBy, 'position'>
sortOrder?: ListSortOrder
}
export interface ListWorkspaceFileFoldersResult {
@@ -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)
@@ -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 }
}