mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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
|
||||
*
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user