diff --git a/apps/sim/app/api/workspaces/[id]/recently-deleted/route.ts b/apps/sim/app/api/workspaces/[id]/recently-deleted/route.ts new file mode 100644 index 00000000000..6f420036bbe --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/recently-deleted/route.ts @@ -0,0 +1,54 @@ +import { listRecentlyDeletedContract } from '@/lib/api/contracts/recently-deleted' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + listRecentlyDeleted, + recentlyDeletedOperations, +} from '@/lib/workspaces/application/list-recently-deleted' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' + +export const dynamic = 'force-dynamic' + +function cursorScope(id: string, type: string, search: string | undefined) { + return cursorScopeKey(cursorRoute(listRecentlyDeletedContract, { id }), { type, search }) +} + +/** GET /api/workspaces/[id]/recently-deleted — One page of the workspace's Recently Deleted view. */ +export const GET = defineInternalJsonRoute({ + contract: listRecentlyDeletedContract, + auth: internalSessionAuth, + operation: recentlyDeletedOperations.list, + rateLimit: internalRateLimits.none({ + reason: 'Replaces the per-type archived lists, which carry no rate limit', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ + workspaceId: params.id, + filter: query.type, + search: query.search, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + limit: query.limit, + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + cursorScope(params.id, query.type, query.search) + ), + }), + useCase: listRecentlyDeleted, + present: ({ items, nextCursorKeys }, { input }) => ({ + items, + nextCursor: writeSortedCursor( + nextCursorKeys, + input.sortBy, + input.sortOrder, + cursorScope(input.workspaceId, input.filter, input.search) + ), + }), +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index f10f6165f50..3607187a16a 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -33,6 +33,7 @@ import type { BreadcrumbDropConfig } from '@/app/workspace/[workspaceId]/compone import { ResourceHeader } from '@/app/workspace/[workspaceId]/components/resource/components/resource-header' import { ResourceOptions } from '@/app/workspace/[workspaceId]/components/resource/components/resource-options' import { SearchHighlight } from '@/app/workspace/[workspaceId]/components/search-highlight/search-highlight' +import { useLoadMoreSentinel } from '@/hooks/use-load-more-sentinel' export interface ResourceColumn { id: string @@ -287,7 +288,7 @@ const ResourceTable = memo(function ResourceTable({ emptyState, }: ResourceTableProps) { const scrollRef = useRef(null) - const loadMoreRef = useRef(null) + const loadMoreRef = useLoadMoreSentinel({ onLoadMore, enabled: Boolean(hasMore) }) const showEmptyState = rows.length === 0 && Boolean(emptyState) @@ -321,20 +322,6 @@ const ResourceTable = memo(function ResourceTable({ } }, [contextMenuRowId]) - useEffect(() => { - if (!onLoadMore || !hasMore) return - const el = loadMoreRef.current - if (!el) return - const observer = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting) onLoadMore() - }, - { rootMargin: '200px' } - ) - observer.observe(el) - return () => observer.disconnect() - }, [onLoadMore, hasMore]) - const hasCheckbox = selectable != null const bodyDrop = rowDragDrop?.body diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/query-plan.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/query-plan.ts deleted file mode 100644 index f774da10a87..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/query-plan.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { RecentlyDeletedTab } from '@/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params' - -interface RecentlyDeletedQueryPlan { - workflows: boolean - folders: boolean - tables: boolean - knowledge: boolean - knowledgeFolders: boolean - tableFolders: boolean - files: boolean - workspaceFolders: boolean - chats: boolean -} - -export function getRecentlyDeletedQueryPlan( - activeTab: RecentlyDeletedTab -): RecentlyDeletedQueryPlan { - const all = activeTab === 'all' - return { - workflows: all || activeTab === 'workflow', - folders: all || activeTab === 'folder', - tables: all || activeTab === 'table', - knowledge: all || activeTab === 'knowledge', - knowledgeFolders: all || activeTab === 'knowledge', - tableFolders: all || activeTab === 'table', - files: all || activeTab === 'file', - workspaceFolders: all || activeTab === 'file', - chats: all || activeTab === 'chat', - } -} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx index e2d2d55cc16..85f19912951 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx @@ -3,13 +3,16 @@ import { useMemo, useState } from 'react' import { Chip, ChipInput, ChipModalTabs } from '@sim/emcn' import { Search } from '@sim/emcn/icons' -import { toError } from '@sim/utils/errors' import { formatDate } from '@sim/utils/formatting' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' import type { ServedFolderResourceType } from '@/lib/api/contracts/folders' -import { useDeploymentShape } from '@/lib/core/config/deployment-shape' +import { + RECENTLY_DELETED_TYPES_BY_FILTER, + type RecentlyDeletedFilter, +} from '@/lib/api/contracts/recently-deleted' +import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { folderedResourceListHref } from '@/app/workspace/[workspaceId]/components/folders' import { type ColumnOption, @@ -18,14 +21,15 @@ import { import { RESOURCE_REGISTRY } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { getRecentlyDeletedQueryPlan } from '@/app/workspace/[workspaceId]/settings/components/recently-deleted/query-plan' import { - type RecentlyDeletedTab, recentlyDeletedParsers, recentlyDeletedSortParams, recentlyDeletedUrlKeys, } from '@/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params' -import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { + SettingsEmptyState, + SettingsQueryErrorState, +} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { RESOURCE_LIST_STACK, @@ -33,15 +37,15 @@ import { } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { useFolders, useRestoreFolder } from '@/hooks/queries/folders' -import { useKnowledgeBasesQuery, useRestoreKnowledgeBase } from '@/hooks/queries/kb/knowledge' -import { useMothershipChats, useRestoreMothershipChat } from '@/hooks/queries/mothership-chats' -import { useRestoreTable, useTablesList } from '@/hooks/queries/tables' -import { useRestoreWorkflow, useWorkflows } from '@/hooks/queries/workflows' -import { - useRestoreWorkspaceFileFolder, - useWorkspaceFileFolders, -} from '@/hooks/queries/workspace-file-folders' -import { useRestoreWorkspaceFile, useWorkspaceFiles } from '@/hooks/queries/workspace-files' +import { useRestoreKnowledgeBase } from '@/hooks/queries/kb/knowledge' +import { useRestoreMothershipChat } from '@/hooks/queries/mothership-chats' +import { useRecentlyDeletedItems } from '@/hooks/queries/recently-deleted' +import { useRestoreTable } from '@/hooks/queries/tables' +import { useRestoreWorkflow } from '@/hooks/queries/workflows' +import { useRestoreWorkspaceFileFolder } from '@/hooks/queries/workspace-file-folders' +import { useRestoreWorkspaceFile } from '@/hooks/queries/workspace-files' +import { useLoadMoreSentinel } from '@/hooks/use-load-more-sentinel' +import { useSearchFilterValue } from '@/hooks/use-search-filter-value' import { useUrlSort } from '@/hooks/use-url-sort' import { useFolderStore } from '@/stores/folders/store' import type { WorkflowFolder } from '@/stores/folders/types' @@ -111,7 +115,8 @@ interface DeletedResource { name: string type: Exclude deletedAt: Date - workspaceId: string + /** The parent folder, for a folder row. */ + parentId: string | null } interface RestoredResourceEntry { @@ -119,7 +124,7 @@ interface RestoredResourceEntry { displayIndex: number } -/** Labels for {@link RECENTLY_DELETED_TABS}, which owns the order. */ +/** Labels for {@link RECENTLY_DELETED_FILTERS}, which owns the order. */ const TABS: { id: ResourceType; label: string }[] = [ { id: 'all', label: 'All' }, { id: 'chat', label: 'Chats' }, @@ -152,26 +157,17 @@ function ResourceIcon({ resource }: { resource: DeletedResource }) { } /** - * Folder trees served by the generic folders API, other than the workflow tree that owns the - * standalone "Folders" tab. Each entry names the deleted-resource type it produces, the - * `resourceType` its rows are read and restored under, and the tab it files beneath — a - * foldered resource's folders belong with the resource, the way a file folder sits under - * Files. Declared once so adding a foldered resource is one row here rather than a fourth - * copy of the query/collect/restore triple below. + * Folder trees served by the generic folders API, other than the workflow tree: the + * `resourceType` each deleted-folder type is restored under. */ const FOLDERED_RESOURCE_TREES = [ - { type: 'knowledge_folder', resourceType: 'knowledge_base', tab: 'knowledge' }, - { type: 'table_folder', resourceType: 'table', tab: 'table' }, + { type: 'knowledge_folder', resourceType: 'knowledge_base' }, + { type: 'table_folder', resourceType: 'table' }, ] as const satisfies readonly { type: Exclude resourceType: ServedFolderResourceType - tab: ResourceType }[] -const FOLDER_TREE_TAB_BY_TYPE = new Map( - FOLDERED_RESOURCE_TREES.map((tree) => [tree.type, tree.tab]) -) - /** * Restore brings a workflow back but deliberately does NOT re-enable its schedules, webhooks, * or chats. Archive overwrites their enabled state without recording what it was, so restoring @@ -185,10 +181,8 @@ const FOLDER_TREE_TAB_BY_TYPE = new Map( */ const PAUSED_AUTOMATION_TYPES = new Set(['workflow', 'folder']) -function matchesActiveTab(resource: DeletedResource, activeTab: ResourceType): boolean { - if (activeTab === 'all') return true - if (activeTab === 'file') return resource.type === 'file' || resource.type === 'workspace_folder' - return resource.type === activeTab || FOLDER_TREE_TAB_BY_TYPE.get(resource.type) === activeTab +function matchesActiveTab(resource: DeletedResource, activeTab: RecentlyDeletedFilter): boolean { + return RECENTLY_DELETED_TYPES_BY_FILTER[activeTab].includes(resource.type) } export function RecentlyDeleted() { @@ -196,7 +190,6 @@ export function RecentlyDeleted() { const router = useRouter() const workspaceId = params?.workspaceId as string const workspacePermissions = useUserPermissionsContext() - const { chatEnabled } = useDeploymentShape() const canEdit = canMutateWorkspaceSettingsSection('recently-deleted', workspacePermissions) const [{ tab: activeTab }, setRecentlyDeletedFilters] = useQueryStates( recentlyDeletedParsers, @@ -213,52 +206,24 @@ export function RecentlyDeleted() { /** * The input is controlled directly by the instant nuqs value; only the URL - * write is debounced. Filtering below is cheap in-memory over a small list, so - * it reads the instant value too. + * write is debounced. The list is searched on the server, so the query reads + * a debounced copy rather than refetching on every keystroke. */ const [urlSearchTerm, setSearchTerm] = useSettingsSearch() + const debouncedSearch = useSearchFilterValue(urlSearchTerm, SEARCH_DEBOUNCE_MS).trim() const [restoringIds, setRestoringIds] = useState>(new Set()) const [restoredItems, setRestoredItems] = useState>(new Map()) - const queryPlan = getRecentlyDeletedQueryPlan(activeTab) - const workflowsQuery = useWorkflows(workspaceId, { - scope: 'archived', - enabled: queryPlan.workflows, - }) - const foldersQuery = useFolders(workspaceId, { - scope: 'archived', - enabled: queryPlan.folders, - }) - const activeFoldersQuery = useFolders(workspaceId, { enabled: queryPlan.folders }) - const tablesQuery = useTablesList(workspaceId, 'archived', { enabled: queryPlan.tables }) - const knowledgeQuery = useKnowledgeBasesQuery(workspaceId, { - scope: 'archived', - enabled: queryPlan.knowledge, - }) - /** - * One archived-folder query per non-workflow tree, in the fixed order of - * {@link FOLDERED_RESOURCE_TREES} so the hook call order stays stable. - */ - const knowledgeFoldersQuery = useFolders(workspaceId, { - scope: 'archived', - resourceType: 'knowledge_base', - enabled: queryPlan.knowledgeFolders, - }) - const tableFoldersQuery = useFolders(workspaceId, { - scope: 'archived', - resourceType: 'table', - enabled: queryPlan.tableFolders, - }) - const filesQuery = useWorkspaceFiles(workspaceId, 'archived', { enabled: queryPlan.files }) - const workspaceFoldersQuery = useWorkspaceFileFolders(workspaceId, 'archived', { - enabled: queryPlan.workspaceFolders, + const deletedQuery = useRecentlyDeletedItems({ + workspaceId, + filter: activeTab, + search: debouncedSearch, + sortBy: sortColumn, + sortOrder: sortDirection, }) - // Restoring a chat navigates to a route that 404s with Chat off, and this - // query's loading/error state feeds the whole panel's. - const chatsQuery = useMothershipChats(workspaceId, { - scope: 'archived', - enabled: queryPlan.chats && chatEnabled, + const activeFoldersQuery = useFolders(workspaceId, { + enabled: activeTab === 'all' || activeTab === 'folder', }) const restoreWorkflow = useRestoreWorkflow() @@ -269,158 +234,26 @@ export function RecentlyDeleted() { const restoreWorkspaceFileFolder = useRestoreWorkspaceFileFolder() const restoreChat = useRestoreMothershipChat(workspaceId) - const activeQueryStates = [ - queryPlan.workflows ? workflowsQuery : null, - queryPlan.folders ? foldersQuery : null, - queryPlan.tables ? tablesQuery : null, - queryPlan.knowledge ? knowledgeQuery : null, - queryPlan.knowledgeFolders ? knowledgeFoldersQuery : null, - queryPlan.tableFolders ? tableFoldersQuery : null, - queryPlan.files ? filesQuery : null, - queryPlan.workspaceFolders ? workspaceFoldersQuery : null, - queryPlan.chats && chatEnabled ? chatsQuery : null, - ] - const isLoading = activeQueryStates.some((query) => query?.isLoading) - const error = activeQueryStates.find((query) => query?.error)?.error - - const resources = useMemo(() => { - const items: DeletedResource[] = [] - - for (const wf of workflowsQuery.data ?? []) { - items.push({ - id: wf.id, - name: wf.name, - type: 'workflow', - deletedAt: wf.archivedAt ? new Date(wf.archivedAt) : new Date(wf.lastModified), - workspaceId: wf.workspaceId ?? workspaceId, - }) - } - - for (const folder of foldersQuery.data ?? []) { - items.push({ - id: folder.id, - name: folder.name, - type: 'folder', - deletedAt: folder.deletedAt ? new Date(folder.deletedAt) : new Date(folder.updatedAt), - workspaceId: folder.workspaceId, - }) - } - - for (const t of tablesQuery.data ?? []) { - items.push({ - id: t.id, - name: t.name, - type: 'table', - deletedAt: new Date(t.archivedAt ?? t.updatedAt), - workspaceId: t.workspaceId, - }) - } - - for (const kb of knowledgeQuery.data ?? []) { - items.push({ - id: kb.id, - name: kb.name, - type: 'knowledge', - deletedAt: kb.deletedAt ? new Date(kb.deletedAt) : new Date(kb.updatedAt), - workspaceId: kb.workspaceId ?? workspaceId, - }) - } - - /** - * Keyed by `tree.type`, not by array position: an index-aligned pairing would silently file - * knowledge folders under Tables (and vice versa) if the const above were ever reordered, - * with no type error and no test to catch it. - */ - const folderTreeData: Record< - (typeof FOLDERED_RESOURCE_TREES)[number]['type'], - typeof knowledgeFoldersQuery.data - > = { - knowledge_folder: knowledgeFoldersQuery.data, - table_folder: tableFoldersQuery.data, - } - FOLDERED_RESOURCE_TREES.forEach((tree) => { - for (const folder of folderTreeData[tree.type] ?? []) { - items.push({ - id: folder.id, - name: folder.name, - type: tree.type, - deletedAt: folder.deletedAt ? new Date(folder.deletedAt) : new Date(folder.updatedAt), - workspaceId: folder.workspaceId, - }) - } - }) - - for (const f of filesQuery.data ?? []) { - items.push({ - id: f.id, - name: f.name, - type: 'file', - deletedAt: new Date(f.deletedAt ?? f.uploadedAt), - workspaceId: f.workspaceId, - }) - } - - for (const wf of workspaceFoldersQuery.data ?? []) { - items.push({ - id: wf.id, - name: wf.name, - type: 'workspace_folder', - deletedAt: wf.deletedAt ? new Date(wf.deletedAt) : new Date(wf.updatedAt), - workspaceId: wf.workspaceId, - }) - } - - for (const chat of chatsQuery.data ?? []) { - if (!chat.deletedAt) continue - items.push({ - id: chat.id, - name: chat.name, - type: 'chat', - deletedAt: chat.deletedAt, - workspaceId, - }) - } + const { hasNextPage, isFetchingNextPage, isFetchNextPageError, fetchNextPage, isLoading, error } = + deletedQuery - return items - }, [ - workflowsQuery.data, - foldersQuery.data, - tablesQuery.data, - knowledgeQuery.data, - knowledgeFoldersQuery.data, - tableFoldersQuery.data, - filesQuery.data, - workspaceFoldersQuery.data, - chatsQuery.data, - workspaceId, - ]) + const loadMoreRef = useLoadMoreSentinel({ + onLoadMore: fetchNextPage, + enabled: hasNextPage && !isFetchingNextPage && !isFetchNextPageError, + }) const filtered = useMemo(() => { - let items = resources.filter((resource) => matchesActiveTab(resource, activeTab)) - const normalized = urlSearchTerm.trim().toLowerCase() - if (normalized) { - items = items.filter((r) => r.name.toLowerCase().includes(normalized)) - } - items.sort((a, b) => { - let cmp = 0 - switch (sortColumn) { - case 'name': - cmp = a.name.localeCompare(b.name) - break - case 'type': - cmp = a.type.localeCompare(b.type) - break - case 'deleted': - cmp = a.deletedAt.getTime() - b.deletedAt.getTime() - break - } - if (cmp !== 0) return sortDirection === 'asc' ? cmp : -cmp - // Ties fall back to A→Z regardless of direction, matching the foldered - // resource lists — otherwise rows tied on type or deletion time land in - // the arbitrary order the resource families were collected in. - return a.name.localeCompare(b.name) || a.id.localeCompare(b.id) - }) + const items: DeletedResource[] = (deletedQuery.data?.pages ?? []).flatMap((page) => + page.items.map((item) => ({ + id: item.id, + name: item.name, + type: item.type, + deletedAt: new Date(item.deletedAt), + parentId: item.parentId, + })) + ) + const normalized = debouncedSearch.toLowerCase() const itemIds = new Set(items.map((item) => item.id)) for (const [id, entry] of restoredItems) { if (itemIds.has(id)) continue @@ -432,25 +265,25 @@ export function RecentlyDeleted() { } return items - }, [resources, activeTab, urlSearchTerm, sortColumn, sortDirection, restoredItems]) + }, [deletedQuery.data, debouncedSearch, activeTab, restoredItems]) - const showNoResults = urlSearchTerm.trim() && filtered.length === 0 && resources.length > 0 + const showNoResults = debouncedSearch !== '' && filtered.length === 0 function handleView(resource: DeletedResource) { if (resource.type === 'folder') { const setExpanded = useFolderStore.getState().setExpanded const byId = new Map() - for (const folder of foldersQuery.data ?? []) byId.set(folder.id, folder) for (const folder of activeFoldersQuery.data ?? []) byId.set(folder.id, folder) - let current: WorkflowFolder | undefined = byId.get(resource.id) - const seen = new Set() + setExpanded(resource.id, true) + const seen = new Set([resource.id]) + let current = resource.parentId ? byId.get(resource.parentId) : undefined while (current && !seen.has(current.id)) { seen.add(current.id) setExpanded(current.id, true) current = current.parentId ? byId.get(current.parentId) : undefined } } - const href = getResourceHref(resource.workspaceId, resource.type, resource.id) + const href = getResourceHref(workspaceId, resource.type, resource.id) router.push(href) } @@ -466,13 +299,13 @@ export function RecentlyDeleted() { case 'workflow': await restoreWorkflow.mutateAsync({ workflowId: resource.id, - workspaceId: resource.workspaceId, + workspaceId, }) break case 'folder': await restoreFolder.mutateAsync({ folderId: resource.id, - workspaceId: resource.workspaceId, + workspaceId, }) break case 'table': @@ -483,13 +316,13 @@ export function RecentlyDeleted() { break case 'file': await restoreWorkspaceFile.mutateAsync({ - workspaceId: resource.workspaceId, + workspaceId, fileId: resource.id, }) break case 'workspace_folder': await restoreWorkspaceFileFolder.mutateAsync({ - workspaceId: resource.workspaceId, + workspaceId, folderId: resource.id, }) break @@ -499,7 +332,7 @@ export function RecentlyDeleted() { if (!tree) break await restoreFolder.mutateAsync({ folderId: resource.id, - workspaceId: resource.workspaceId, + workspaceId, resourceType: tree.resourceType, }) break @@ -544,17 +377,20 @@ export function RecentlyDeleted() { ({ value: tab.id, label: tab.label }))} value={activeTab} - onChange={(v) => setRecentlyDeletedFilters({ tab: v as RecentlyDeletedTab })} + onChange={(v) => setRecentlyDeletedFilters({ tab: v as RecentlyDeletedFilter })} /> - {error ? ( - - {toError(error).message || 'Failed to load deleted items'} - + {error && !deletedQuery.data ? ( + void deletedQuery.refetch()} + /> ) : isLoading && filtered.length === 0 ? null : filtered.length === 0 ? ( showNoResults ? ( - {`No items found matching \u201c${urlSearchTerm}\u201d`} + {`No items found matching \u201c${debouncedSearch}\u201d`} ) : ( No deleted items @@ -608,6 +444,17 @@ export function RecentlyDeleted() { /> ) })} + {isFetchNextPageError ? ( + void fetchNextPage()} + variant='inline' + /> + ) : ( +
+ )}
)} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params.ts index 62ae0141460..0ac68980fea 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params.ts @@ -1,27 +1,10 @@ import { parseAsStringLiteral } from 'nuqs/server' +import { + RECENTLY_DELETED_FILTERS, + RECENTLY_DELETED_SORT_COLUMNS, +} from '@/lib/api/contracts/recently-deleted' import { createSortParams } from '@/lib/url-state' -/** - * Selectable resource-type tabs in the Recently Deleted view, after the default - * `all`: the sidebar's top-down order, so the tabs read the way the user already - * reads these resources. `TABS` in `recently-deleted.tsx` labels this same list — - * keep the two in step. - */ -export const RECENTLY_DELETED_TABS = [ - 'all', - 'chat', - 'table', - 'file', - 'knowledge', - 'workflow', - 'folder', -] as const - -export type RecentlyDeletedTab = (typeof RECENTLY_DELETED_TABS)[number] - -/** Sortable columns for the deleted-items list. */ -export const RECENTLY_DELETED_SORT_COLUMNS = ['deleted', 'name', 'type'] as const - /** * Shared `sort` + `dir` params for the deleted-items list. Default sort: * most-recently-deleted first. Consumed via `useUrlSort` in @@ -44,7 +27,7 @@ export const recentlyDeletedSortParams = createSortParams(RECENTLY_DELETED_SORT_ * deliberately not redeclared here (two definitions of one wire key drift). */ export const recentlyDeletedParsers = { - tab: parseAsStringLiteral(RECENTLY_DELETED_TABS).withDefault('all'), + tab: parseAsStringLiteral(RECENTLY_DELETED_FILTERS).withDefault('all'), } as const /** Tab/filter/sort view-state: clean URLs, no back-stack churn. */ diff --git a/apps/sim/hooks/queries/folders.ts b/apps/sim/hooks/queries/folders.ts index 356942687b5..97b42edb5dc 100644 --- a/apps/sim/hooks/queries/folders.ts +++ b/apps/sim/hooks/queries/folders.ts @@ -24,6 +24,7 @@ import { createOptimisticMutationHandlers, generateTempId, } from '@/hooks/queries/utils/optimistic-mutation' +import { recentlyDeletedKeys } from '@/hooks/queries/utils/recently-deleted-keys' import { tableKeys } from '@/hooks/queries/utils/table-keys' import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order' import { getWorkflows } from '@/hooks/queries/utils/workflow-cache' @@ -315,6 +316,7 @@ export function useRestoreFolder() { }) }, onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ queryKey: recentlyDeletedKeys.lists() }) const resourceType = variables.resourceType ?? 'workflow' queryClient.invalidateQueries({ queryKey: folderKeys.resource(resourceType) }) return invalidateCascadedResourceLists(queryClient, resourceType, variables.workspaceId) diff --git a/apps/sim/hooks/queries/kb/knowledge.ts b/apps/sim/hooks/queries/kb/knowledge.ts index 8a9a4e1d349..754f8df5236 100644 --- a/apps/sim/hooks/queries/kb/knowledge.ts +++ b/apps/sim/hooks/queries/kb/knowledge.ts @@ -71,6 +71,7 @@ import { type KnowledgeQueryScope, knowledgeKeys, } from '@/hooks/queries/utils/knowledge-keys' +import { recentlyDeletedKeys } from '@/hooks/queries/utils/recently-deleted-keys' import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys' const logger = createLogger('KnowledgeQueries') @@ -1101,6 +1102,7 @@ export function useRestoreKnowledgeBase() { }) }, onSettled: () => { + queryClient.invalidateQueries({ queryKey: recentlyDeletedKeys.lists() }) queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() }) }, }) diff --git a/apps/sim/hooks/queries/mothership-chats.ts b/apps/sim/hooks/queries/mothership-chats.ts index c40f5b6cf10..f1d9a0693de 100644 --- a/apps/sim/hooks/queries/mothership-chats.ts +++ b/apps/sim/hooks/queries/mothership-chats.ts @@ -40,6 +40,7 @@ import { } from '@/lib/mothership/request/session/file-preview-session-contract' import { isStreamBatchEvent, type StreamBatchEvent } from '@/lib/mothership/request/session/types' import type { MothershipResource } from '@/lib/mothership/resources/types' +import { recentlyDeletedKeys } from '@/hooks/queries/utils/recently-deleted-keys' import { useMothershipEffortStore } from '@/stores/mothership-effort/store' import { useMothershipQueueStore } from '@/stores/mothership-queue/store' @@ -405,6 +406,7 @@ export function useRestoreMothershipChat(owner?: MothershipChatOwner) { useMothershipQueueStore.getState().liftDelete(chatId, context.deleteSeen) }, onSettled: () => { + queryClient.invalidateQueries({ queryKey: recentlyDeletedKeys.lists() }) queryClient.invalidateQueries({ queryKey: mothershipChatKeys.ownerLists(owner) }) }, }) diff --git a/apps/sim/hooks/queries/recently-deleted.ts b/apps/sim/hooks/queries/recently-deleted.ts new file mode 100644 index 00000000000..41bcf5ee66a --- /dev/null +++ b/apps/sim/hooks/queries/recently-deleted.ts @@ -0,0 +1,52 @@ +import { useInfiniteQuery } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + listRecentlyDeletedContract, + type RecentlyDeletedFilter, + type RecentlyDeletedSortColumn, +} from '@/lib/api/contracts/recently-deleted' +import type { ListSortOrder } from '@/lib/api/list-query' +import { recentlyDeletedKeys } from '@/hooks/queries/utils/recently-deleted-keys' +import { shouldRetrySettingsRead } from '@/hooks/queries/utils/settings-read-retry' + +/** Always refetched on open, so a resource deleted anywhere else shows up here. */ +const RECENTLY_DELETED_STALE_TIME = 0 + +interface RecentlyDeletedListParams { + workspaceId: string + filter: RecentlyDeletedFilter + search?: string + sortBy: RecentlyDeletedSortColumn + sortOrder: ListSortOrder +} + +/** The Recently Deleted view, one server-searched, server-sorted page at a time. */ +export function useRecentlyDeletedItems({ + workspaceId, + filter, + search, + sortBy, + sortOrder, +}: RecentlyDeletedListParams) { + const normalizedSearch = search || undefined + return useInfiniteQuery({ + queryKey: recentlyDeletedKeys.list(workspaceId, filter, normalizedSearch, sortBy, sortOrder), + initialPageParam: undefined as string | undefined, + queryFn: ({ signal, pageParam }) => + requestJson(listRecentlyDeletedContract, { + params: { id: workspaceId }, + query: { type: filter, search: normalizedSearch, sortBy, sortOrder, cursor: pageParam }, + signal, + }), + getNextPageParam: (page) => page.nextCursor ?? undefined, + enabled: Boolean(workspaceId), + staleTime: RECENTLY_DELETED_STALE_TIME, + placeholderData: (previous, previousQuery) => + recentlyDeletedKeys + .workspaceLists(workspaceId) + .every((part, index) => previousQuery?.queryKey[index] === part) + ? previous + : undefined, + retry: shouldRetrySettingsRead, + }) +} diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index cccacc8e2e7..2852fa81bb7 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -120,6 +120,7 @@ import type { UploadProgressEvent } from '@/lib/uploads/client/types' import { uploadFileSession } from '@/lib/uploads/client/upload-session' import { useTimezone } from '@/hooks/queries/general-settings' import { folderKeys } from '@/hooks/queries/utils/folder-keys' +import { recentlyDeletedKeys } from '@/hooks/queries/utils/recently-deleted-keys' import { TABLE_LIST_STALE_TIME, TABLE_VIEWS_STALE_TIME, @@ -1848,6 +1849,7 @@ export function useRestoreTable() { return Promise.all([ queryClient.invalidateQueries({ queryKey: tableKeys.lists() }), queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId) }), + queryClient.invalidateQueries({ queryKey: recentlyDeletedKeys.lists() }), queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) }), ]) }, diff --git a/apps/sim/hooks/queries/utils/recently-deleted-keys.ts b/apps/sim/hooks/queries/utils/recently-deleted-keys.ts new file mode 100644 index 00000000000..4ef0c99a475 --- /dev/null +++ b/apps/sim/hooks/queries/utils/recently-deleted-keys.ts @@ -0,0 +1,31 @@ +import type { + RecentlyDeletedFilter, + RecentlyDeletedSortColumn, +} from '@/lib/api/contracts/recently-deleted' +import type { ListSortOrder } from '@/lib/api/list-query' + +/** + * Standalone so each resource's restore mutation can invalidate the Recently Deleted view + * without importing its hook module. A restore can bring back more than the row it was + * called on (a folder restores its contents), so the whole view refetches. + */ +export const recentlyDeletedKeys = { + all: ['recentlyDeleted'] as const, + lists: () => [...recentlyDeletedKeys.all, 'list'] as const, + /** Every list in one workspace — the scope a previous page may be carried within. */ + workspaceLists: (workspaceId: string) => [...recentlyDeletedKeys.lists(), workspaceId] as const, + list: ( + workspaceId: string, + filter: RecentlyDeletedFilter, + search: string | undefined, + sortBy: RecentlyDeletedSortColumn, + sortOrder: ListSortOrder + ) => + [ + ...recentlyDeletedKeys.workspaceLists(workspaceId), + filter, + search ?? '', + sortBy, + sortOrder, + ] as const, +} diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index b1c1f770164..150b1e917dd 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -31,6 +31,7 @@ import { fetchDeploymentVersionState } from '@/hooks/queries/utils/fetch-deploym import { fetchWorkflowEnvelope } from '@/hooks/queries/utils/fetch-workflow-envelope' import { getFolderMap } from '@/hooks/queries/utils/folder-cache' import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists' +import { recentlyDeletedKeys } from '@/hooks/queries/utils/recently-deleted-keys' import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order' import { getWorkflows, removeWorkflowFromActiveCache } from '@/hooks/queries/utils/workflow-cache' import { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys' @@ -700,6 +701,7 @@ export function useRestoreWorkflow() { return requestJson(restoreWorkflowContract, { params: { id: workflowId } }) }, onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ queryKey: recentlyDeletedKeys.lists() }) return invalidateWorkflowLists(queryClient, variables.workspaceId, ['active', 'archived']) }, }) diff --git a/apps/sim/hooks/queries/workspace-file-folders.ts b/apps/sim/hooks/queries/workspace-file-folders.ts index d7dd930dcd1..014d72867b1 100644 --- a/apps/sim/hooks/queries/workspace-file-folders.ts +++ b/apps/sim/hooks/queries/workspace-file-folders.ts @@ -16,6 +16,7 @@ import { buildWorkspaceFileFolderDisplayPath, parseWorkspaceFileFolderDisplayPath, } from '@/lib/workspace-files/folder-display-path' +import { recentlyDeletedKeys } from '@/hooks/queries/utils/recently-deleted-keys' import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' type WorkspaceFileFolderScope = 'active' | 'archived' | 'all' @@ -249,6 +250,7 @@ export function useRestoreWorkspaceFileFolder() { toast.error(toError(err).message) }, onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ queryKey: recentlyDeletedKeys.lists() }) invalidateWorkspaceFileBrowsers(queryClient, variables.workspaceId) }, }) diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index b11b0f53aa4..86f1ea5c367 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -30,6 +30,7 @@ import type { UploadProgressEvent } from '@/lib/uploads/client/types' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import type { UserFile } from '@/executor/types' import { findWorkspaceFileBySrc } from '@/hooks/queries/utils/find-workspace-file-by-src' +import { recentlyDeletedKeys } from '@/hooks/queries/utils/recently-deleted-keys' import { type ImageDimensionsSource, useFileContentSource } from '@/hooks/use-file-content-source' const logger = createLogger('WorkspaceFilesQuery') @@ -802,6 +803,7 @@ export function useRestoreWorkspaceFile() { toast.error(toError(err).message) }, onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ queryKey: recentlyDeletedKeys.lists() }) queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.workspaceLists(variables.workspaceId), }) diff --git a/apps/sim/hooks/use-load-more-sentinel.ts b/apps/sim/hooks/use-load-more-sentinel.ts new file mode 100644 index 00000000000..9436bbc94b1 --- /dev/null +++ b/apps/sim/hooks/use-load-more-sentinel.ts @@ -0,0 +1,32 @@ +'use client' + +import { useCallback } from 'react' + +interface UseLoadMoreSentinelOptions { + /** Loads the next page; absent means the list has no further pages to request. */ + onLoadMore: (() => void) | undefined + /** Whether reaching the sentinel should load more right now. */ + enabled: boolean +} + +/** + * A callback ref for the element at the end of a paged list: `onLoadMore` runs when it scrolls + * within 200px of view, while `enabled`. Pass `enabled: false` during a fetch, so the + * observer is rebuilt when the fetch ends and fires again if the sentinel is still in view. + */ +export function useLoadMoreSentinel({ onLoadMore, enabled }: UseLoadMoreSentinelOptions) { + return useCallback( + (sentinel: HTMLDivElement | null) => { + if (!sentinel || !onLoadMore || !enabled) return + const observer = new IntersectionObserver( + ([entry]) => { + if (entry?.isIntersecting) onLoadMore() + }, + { rootMargin: '200px' } + ) + observer.observe(sentinel) + return () => observer.disconnect() + }, + [onLoadMore, enabled] + ) +} diff --git a/apps/sim/lib/api/contracts/recently-deleted.ts b/apps/sim/lib/api/contracts/recently-deleted.ts new file mode 100644 index 00000000000..72e1ac30236 --- /dev/null +++ b/apps/sim/lib/api/contracts/recently-deleted.ts @@ -0,0 +1,91 @@ +import { z } from 'zod' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' + +/** Every kind of row Recently Deleted lists. */ +export const RECENTLY_DELETED_ITEM_TYPES = [ + 'workflow', + 'folder', + 'table', + 'table_folder', + 'knowledge', + 'knowledge_folder', + 'file', + 'workspace_folder', + 'chat', +] as const + +/** + * The view's tabs, after the default `all`, in the sidebar's top-down order so they read the + * way the user already reads these resources. A foldered resource's tab also lists its own + * folders (Tables lists table folders); `folder` is the workflow folder tree. + */ +export const RECENTLY_DELETED_FILTERS = [ + 'all', + 'chat', + 'table', + 'file', + 'knowledge', + 'workflow', + 'folder', +] as const + +export const RECENTLY_DELETED_SORT_COLUMNS = ['deleted', 'name', 'type'] as const + +const RECENTLY_DELETED_PAGE_SIZE = 50 + +const recentlyDeletedItemSchema = z.object({ + id: z.string(), + name: z.string(), + type: z.enum(RECENTLY_DELETED_ITEM_TYPES), + deletedAt: z.string(), + /** Parent folder id for folder rows; `null` for every other type. */ + parentId: z.string().nullable(), +}) + +export type RecentlyDeletedItem = z.output + +const listRecentlyDeletedQuerySchema = z.object({ + type: z.enum(RECENTLY_DELETED_FILTERS).default('all'), + search: z + .string() + .trim() + .max(200, 'search must be at most 200 characters') + .optional() + .transform((value) => value || undefined), + sortBy: z.enum(RECENTLY_DELETED_SORT_COLUMNS).default('deleted'), + sortOrder: z.enum(['asc', 'desc']).default('desc'), + limit: z.coerce.number().int().min(1).max(100).default(RECENTLY_DELETED_PAGE_SIZE), + cursor: z.string().min(1, 'cursor must be a non-empty token').max(2048).optional(), +}) + +export type RecentlyDeletedFilter = z.output['type'] +export type RecentlyDeletedSortColumn = z.output['sortBy'] + +/** The item types each tab lists. */ +export const RECENTLY_DELETED_TYPES_BY_FILTER: Record< + RecentlyDeletedFilter, + readonly RecentlyDeletedItem['type'][] +> = { + all: RECENTLY_DELETED_ITEM_TYPES, + chat: ['chat'], + table: ['table', 'table_folder'], + file: ['file', 'workspace_folder'], + knowledge: ['knowledge', 'knowledge_folder'], + workflow: ['workflow'], + folder: ['folder'], +} + +export const listRecentlyDeletedContract = defineRouteContract({ + method: 'GET', + path: '/api/workspaces/[id]/recently-deleted', + params: z.object({ id: workspaceIdSchema }), + query: listRecentlyDeletedQuerySchema, + response: { + mode: 'json', + schema: z.object({ + items: z.array(recentlyDeletedItemSchema).max(100), + nextCursor: z.string().nullable(), + }), + }, +}) diff --git a/apps/sim/lib/api/list-query.ts b/apps/sim/lib/api/list-query.ts index 80e41a086ac..d1d08288c99 100644 --- a/apps/sim/lib/api/list-query.ts +++ b/apps/sim/lib/api/list-query.ts @@ -35,7 +35,7 @@ export type ListSortOrder = (typeof LIST_SORT_ORDERS)[number] * Case-insensitive substring predicate for a v2 `search` term, or `undefined` * when the caller did not search (which drops out of an `and(...)`). */ -export function searchFilter(column: Column, term: string | undefined): SQL | undefined { +export function searchFilter(column: Column | SQL, term: string | undefined): SQL | undefined { if (term === undefined) return undefined return ilike(column, `%${escapeLikePattern(term)}%`) } @@ -69,10 +69,19 @@ export interface KeysetKey { * {@link keysetAfter}. Set only on a key an index serves in order. */ boundsScan?: boolean + /** + * A direction this key keeps whatever order the caller requested — a tiebreaker that + * stays A→Z under a descending primary sort. Absent means the requested order. + */ + direction?: ListSortOrder +} + +function keyDirection(key: KeysetKey, order: ListSortOrder): ListSortOrder { + return key.direction ?? order } /** A text key — names, titles, ids. */ -export function textKey(column: Column, read: (row: Row) => string): KeysetKey { +export function textKey(column: SQLWrapper, read: (row: Row) => string): KeysetKey { return { expr: column, encode: read, @@ -199,7 +208,7 @@ export function classifyTimestampCursor(value: CursorKey): 'precise' | 'date' | * wall-clock time: `to_char` renders a `timestamptz` in the session time zone, * which the trailing `Z` would misstate. */ -export function preciseTimestampText(column: Column): SQL { +export function preciseTimestampText(column: Column | SQL): SQL { return sql`to_char(${column}, 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')` } @@ -214,7 +223,7 @@ export function preciseTimestampText(column: Column): SQL { * value {@link classifyTimestampCursor} calls precise. */ export function preciseTimestampKey( - column: Column, + column: SQLWrapper, read: (row: Row) => string ): KeysetKey { return { @@ -240,6 +249,11 @@ export function listOrderBy(keys: readonly SQLWrapper[], order: ListSortOrder): return keys.map((key) => direction(key)) } +/** `ORDER BY` for a keyset, each key in its own {@link KeysetKey.direction} or the requested one. */ +export function keysetOrderBy(keys: readonly KeysetKey[], order: ListSortOrder): SQL[] { + return keys.map((key) => sortDirection(keyDirection(key, order))(key.expr)) +} + /** The `expr` of each keyset key, for `ORDER BY`. */ export function keysetColumns(keys: readonly KeysetKey[]): SQLWrapper[] { return keys.map((key) => key.expr) @@ -302,8 +316,8 @@ export function keysetPage( /** * The `WHERE` half of the keyset: strictly after `values` in the requested - * direction, expanded lexicographically so ties on a leading key fall through - * to the next one. + * direction (or a key's own {@link KeysetKey.direction}), expanded + * lexicographically so ties on a leading key fall through to the next one. * * When the leading key {@link KeysetKey.boundsScan | bounds the scan}, the * cursor also carries that key's inclusive bound, which the expansion already @@ -329,13 +343,15 @@ export function keysetAfter( bound.push(value) } - const beyond = order === 'asc' ? gt : lt const clauses = keys.map((key, i) => - and(...keys.slice(0, i).map((prior, j) => eq(prior.expr, bound[j])), beyond(key.expr, bound[i])) + and( + ...keys.slice(0, i).map((prior, j) => eq(prior.expr, bound[j])), + (keyDirection(key, order) === 'asc' ? gt : lt)(key.expr, bound[i]) + ) ) const after = or(...clauses) if (!after) return null if (keys.length === 1 || !keys[0].boundsScan) return after - const atOrBeyond = order === 'asc' ? gte : lte + const atOrBeyond = keyDirection(keys[0], order) === 'asc' ? gte : lte return and(atOrBeyond(keys[0].expr, bound[0]), after) ?? null } diff --git a/apps/sim/lib/mothership/chat/application/read-attachment.test.ts b/apps/sim/lib/mothership/chat/application/read-attachment.test.ts index f66c523ff7a..91b69e596ad 100644 --- a/apps/sim/lib/mothership/chat/application/read-attachment.test.ts +++ b/apps/sim/lib/mothership/chat/application/read-attachment.test.ts @@ -24,7 +24,7 @@ vi.mock('@/lib/uploads/contexts/organization-assistant/application', () => ({ import { readChatAttachment } from './read-attachment' -const mockLoadCopilotChatMessages = mothershipChatLifecycleMockFns.mockLoadCopilotChatMessages +const mockLoadAttachments = mothershipChatLifecycleMockFns.mockLoadChatFileAttachmentsNamed const principal = { kind: 'session', userId: 'user', sessionId: 'session' } as const const file = { @@ -41,7 +41,7 @@ beforeEach(() => { userId: 'user', role: 'member', }) - mockLoadCopilotChatMessages.mockResolvedValue([{ fileAttachments: [file] }]) + mockLoadAttachments.mockResolvedValue([file]) mocks.read.mockResolvedValue({ buffer: Buffer.from('a,b'), name: 'a.csv' }) }) describe('organization chat upload references', () => { @@ -49,7 +49,7 @@ describe('organization chat upload references', () => { 'reads %s only through a saved attachment in the owned chat', async (reference) => { await readChatAttachment.execute({ principal, input: { chatId: 'chat', reference } }) - expect(mockLoadCopilotChatMessages).toHaveBeenCalledWith('chat') + expect(mockLoadAttachments).toHaveBeenCalledWith('chat', reference.slice('uploads/'.length)) expect(mocks.read).toHaveBeenCalledWith({ principal, organizationId: 'org', @@ -59,7 +59,8 @@ describe('organization chat upload references', () => { }) } ) - it('refuses a file from another chat before storage', async () => { + it('refuses a reference with no saved attachment before storage', async () => { + mockLoadAttachments.mockResolvedValue([]) await expect( readChatAttachment.execute({ principal, @@ -69,9 +70,9 @@ describe('organization chat upload references', () => { expect(mocks.read).not.toHaveBeenCalled() }) it('requires an ID for ambiguous filenames while repeated mentions of one attachment remain readable', async () => { - mockLoadCopilotChatMessages.mockResolvedValue([ - { fileAttachments: [file, file, { ...file, id: 'upload-b', key: 'other' }] }, - ]) + mockLoadAttachments + .mockResolvedValueOnce([file, file, { ...file, id: 'upload-b', key: 'other' }]) + .mockResolvedValueOnce([file, file]) await expect( readChatAttachment.execute({ principal, @@ -95,7 +96,7 @@ describe('organization chat upload references', () => { input: { chatId: 'chat', reference: 'uploads/a.csv' }, }) ).rejects.toMatchObject({ code: 'not_found' }) - expect(mockLoadCopilotChatMessages).not.toHaveBeenCalled() + expect(mockLoadAttachments).not.toHaveBeenCalled() }) it('propagates revoked membership before transcript or byte access', async () => { organizationAuthorizationMockFns.mockAuthorizeOrganizationOperation.mockRejectedValue( @@ -107,7 +108,7 @@ describe('organization chat upload references', () => { input: { chatId: 'chat', reference: 'uploads/a.csv' }, }) ).rejects.toThrow('membership revoked') - expect(mockLoadCopilotChatMessages).not.toHaveBeenCalled() + expect(mockLoadAttachments).not.toHaveBeenCalled() expect(mocks.read).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/mothership/chat/application/read-attachment.ts b/apps/sim/lib/mothership/chat/application/read-attachment.ts index 62c79e6d27f..73b4d0e5580 100644 --- a/apps/sim/lib/mothership/chat/application/read-attachment.ts +++ b/apps/sim/lib/mothership/chat/application/read-attachment.ts @@ -4,7 +4,7 @@ import { defineOrganizationOperation } from '@/lib/core/application/organization import { OrchestrationError } from '@/lib/core/orchestration/types' import { defineAuthorizedChatUseCase } from '@/lib/mothership/chat/application/authorized-chat-use-case' import { resolveOwnedChatContext } from '@/lib/mothership/chat/application/context' -import { loadCopilotChatMessages } from '@/lib/mothership/chat/lifecycle' +import { loadChatFileAttachmentsNamed } from '@/lib/mothership/chat/lifecycle' import { readOrganizationChatAttachment } from '@/lib/uploads/contexts/organization-assistant/application' import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization' @@ -53,14 +53,8 @@ export const readChatAttachment = defineAuthorizedChatUseCase({ } catch { throw new OrchestrationError('validation', 'Invalid attachment reference') } - const messages = await loadCopilotChatMessages(context.chatId) - const matches = new Map( - messages.flatMap((message) => - (message.fileAttachments ?? []) - .filter((file) => file.id === name || file.filename === name) - .map((file) => [file.id, file] as const) - ) - ) + const attachments = await loadChatFileAttachmentsNamed(context.chatId, name) + const matches = new Map(attachments.map((file) => [file.id, file] as const)) if (matches.size !== 1) throw new OrchestrationError( 'not_found', diff --git a/apps/sim/lib/mothership/chat/file-attachments.integration.ts b/apps/sim/lib/mothership/chat/file-attachments.integration.ts new file mode 100644 index 00000000000..05bc6668a0b --- /dev/null +++ b/apps/sim/lib/mothership/chat/file-attachments.integration.ts @@ -0,0 +1,123 @@ +/** Exercises the targeted attachment read against real PostgreSQL transcripts. */ +import { db } from '@sim/db' +import { copilotChats, copilotMessages, user } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + loadChatFileAttachmentsNamed, + loadCopilotChatMessages, +} from '@/lib/mothership/chat/lifecycle' +import { appendCopilotChatMessages } from '@/lib/mothership/chat/messages-store' +import { + buildPersistedUserMessage, + type PersistedFileAttachment, + type PersistedMessage, +} from '@/lib/mothership/chat/persisted-message' + +/** The whole-transcript scan this query replaced, kept as the equivalence oracle. */ +function collectFromTranscript( + messages: PersistedMessage[], + name: string +): PersistedFileAttachment[] { + return messages.flatMap((message) => + (message.fileAttachments ?? []).filter((file) => file.id === name || file.filename === name) + ) +} + +function attachment(id: string, filename: string): PersistedFileAttachment { + return { id, key: `assistant/${id}/${filename}`, filename, media_type: 'text/csv', size: 1 } +} + +function userTurn(fileAttachments?: PersistedFileAttachment[]): PersistedMessage { + return buildPersistedUserMessage({ id: generateId(), content: 'turn', fileAttachments }) +} + +describe('chat file attachment lookup in PostgreSQL', () => { + const userId = generateId() + + async function createChat(): Promise { + const [chat] = await db + .insert(copilotChats) + .values({ userId, type: 'mothership' }) + .returning({ id: copilotChats.id }) + return chat.id + } + + beforeAll(async () => { + const now = new Date() + await db.insert(user).values({ + id: userId, + name: 'Attachment lookup fixture', + email: `${userId}@fixture.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + }) + + afterAll(async () => { + await db.delete(copilotChats).where(eq(copilotChats.userId, userId)) + await db.delete(user).where(eq(user.id, userId)) + await db.$client.end() + }) + + it('returns id and filename matches in transcript order, matching the transcript scan', async () => { + const chatId = await createChat() + const first = attachment('upload-a', 'report.csv') + const second = attachment('upload-b', 'report.csv') + await appendCopilotChatMessages(chatId, [ + userTurn([first, attachment('upload-c', 'other.csv')]), + userTurn(), + userTurn([second, first]), + ]) + const transcript = await loadCopilotChatMessages(chatId) + + for (const name of ['report.csv', 'upload-a', 'upload-c', 'missing.csv']) { + expect(await loadChatFileAttachmentsNamed(chatId, name)).toEqual( + collectFromTranscript(transcript, name) + ) + } + expect(await loadChatFileAttachmentsNamed(chatId, 'report.csv')).toEqual([first, second, first]) + }) + + it('ignores malformed attachments and non-string ids that spell the name', async () => { + const chatId = await createChat() + await db.insert(copilotMessages).values([ + { + chatId, + messageId: generateId(), + role: 'user', + seq: 0, + content: { role: 'user', content: 'x', fileAttachments: { id: '7', filename: '7' } }, + }, + { + chatId, + messageId: generateId(), + role: 'user', + seq: 1, + content: { + role: 'user', + content: 'y', + fileAttachments: ['7', null, { id: 7, filename: 'seven.csv' }, attachment('7', 'ok.csv')], + }, + }, + ]) + + expect(await loadChatFileAttachmentsNamed(chatId, '7')).toEqual([attachment('7', 'ok.csv')]) + }) + + it('skips deleted messages and other chats', async () => { + const chatId = await createChat() + const otherChatId = await createChat() + const deleted = userTurn([attachment('upload-deleted', 'gone.csv')]) + await appendCopilotChatMessages(chatId, [deleted]) + await appendCopilotChatMessages(otherChatId, [userTurn([attachment('upload-x', 'gone.csv')])]) + await db + .update(copilotMessages) + .set({ deletedAt: new Date() }) + .where(and(eq(copilotMessages.chatId, chatId), eq(copilotMessages.messageId, deleted.id))) + + expect(await loadChatFileAttachmentsNamed(chatId, 'gone.csv')).toEqual([]) + }) +}) diff --git a/apps/sim/lib/mothership/chat/lifecycle.ts b/apps/sim/lib/mothership/chat/lifecycle.ts index 7d40c26c769..3d8bb3c0151 100644 --- a/apps/sim/lib/mothership/chat/lifecycle.ts +++ b/apps/sim/lib/mothership/chat/lifecycle.ts @@ -6,7 +6,7 @@ import { authorizeWorkflowByWorkspacePermission, getActiveWorkflowRecord, } from '@sim/platform-authz/workflow' -import { and, asc, eq, isNull, sql } from 'drizzle-orm' +import { and, asc, eq, isNull, type SQL, sql } from 'drizzle-orm' import { asOrchestrationError } from '@/lib/core/orchestration/types' import { type ConversationMode, @@ -18,6 +18,7 @@ import { authorizeOrganizationChatCancellation, } from '@/lib/mothership/chat/organization-chats' import { + type PersistedFileAttachment, type PersistedMessage, stripToolResultOutput, } from '@/lib/mothership/chat/persisted-message' @@ -81,6 +82,25 @@ const copilotChatLegacyDetailColumns = { config: copilotChats.config, } as const +/** Canonical transcript order; `NULLS LAST` sorts a not-yet-sequenced row after sequenced ones. */ +const TRANSCRIPT_ORDER = [ + sql`${copilotMessages.seq} asc nulls last`, + asc(copilotMessages.createdAt), + asc(copilotMessages.id), +] + +/** + * Each element of a message's `content -> field` array as `alias(value, ordinal)`, for a + * lateral join that reads one array of every message instead of whole transcripts. A + * message whose field is missing or not an array contributes nothing. + */ +function messageArrayElements(field: string, alias: string): SQL { + const array = sql`${copilotMessages.content} -> ${field}::text` + return sql`jsonb_array_elements( + case when jsonb_typeof(${array}) = 'array' then ${array} else '[]'::jsonb end + ) with ordinality as ${sql.identifier(alias)}(value, ordinal)` +} + /** * Load a chat's transcript from the normalized `copilot_messages` table in * canonical order (`seq` first, then `created_at`/`id` as a deterministic @@ -94,15 +114,38 @@ export async function loadCopilotChatMessages(chatId: string): Promise stripToolResultOutput(row.content as PersistedMessage)) } +/** + * File attachments on a chat's live messages whose `id` or `filename` equals `name`, in + * transcript order. Reads only the matching `fileAttachments` entries instead of + * materializing the whole transcript. The SQL text comparison admits a superset of the + * strict match, which is re-applied here so a non-string `id` never matches its spelling. + */ +export async function loadChatFileAttachmentsNamed( + chatId: string, + name: string +): Promise { + const rows = await db + .select({ attachment: sql`file_attachment.value` }) + .from(copilotMessages) + .crossJoinLateral(messageArrayElements('fileAttachments', 'file_attachment')) + .where( + and( + eq(copilotMessages.chatId, chatId), + isNull(copilotMessages.deletedAt), + sql`(file_attachment.value ->> 'id' = ${name} or file_attachment.value ->> 'filename' = ${name})` + ) + ) + .orderBy(...TRANSCRIPT_ORDER, sql`file_attachment.ordinal`) + return rows + .map((row) => row.attachment) + .filter((attachment) => attachment.id === name || attachment.filename === name) +} + /** * MCP server ids tagged (`/name`) on a chat's live messages, in first-tagged * transcript order. Reads only the `mcp` entries of each message's `contexts` @@ -113,14 +156,7 @@ export async function loadChatMcpServerIds(chatId: string): Promise { const rows = await db .select({ serverId: sql`mcp_context.value ->> 'serverId'` }) .from(copilotMessages) - .crossJoinLateral( - sql`jsonb_array_elements( - case when jsonb_typeof(${copilotMessages.content} -> 'contexts') = 'array' - then ${copilotMessages.content} -> 'contexts' - else '[]'::jsonb - end - ) with ordinality as mcp_context(value, ordinal)` - ) + .crossJoinLateral(messageArrayElements('contexts', 'mcp_context')) .where( and( eq(copilotMessages.chatId, chatId), @@ -130,12 +166,7 @@ export async function loadChatMcpServerIds(chatId: string): Promise { sql`mcp_context.value ->> 'serverId' <> ''` ) ) - .orderBy( - sql`${copilotMessages.seq} asc nulls last`, - asc(copilotMessages.createdAt), - asc(copilotMessages.id), - sql`mcp_context.ordinal` - ) + .orderBy(...TRANSCRIPT_ORDER, sql`mcp_context.ordinal`) return [...new Set(rows.map((row) => row.serverId))] } diff --git a/apps/sim/lib/mothership/request/go/file-preview-adapter.test.ts b/apps/sim/lib/mothership/request/go/file-preview-adapter.test.ts index 0c3038b8cdd..7e416b15e78 100644 --- a/apps/sim/lib/mothership/request/go/file-preview-adapter.test.ts +++ b/apps/sim/lib/mothership/request/go/file-preview-adapter.test.ts @@ -307,7 +307,7 @@ describe('processFilePreviewStreamEvent — preview target resolution', () => { beforeEach(() => { executeCopilotFileUseCaseMock.mockResolvedValue({ - files: [{ id: 'file-9', name: 'notes.md', folderPath: null }], + files: new Map([['files/notes.md', { id: 'file-9', name: 'notes.md', folderPath: null }]]), }) }) diff --git a/apps/sim/lib/mothership/request/go/file-preview-adapter.ts b/apps/sim/lib/mothership/request/go/file-preview-adapter.ts index 7599fae995a..94fac8cc557 100644 --- a/apps/sim/lib/mothership/request/go/file-preview-adapter.ts +++ b/apps/sim/lib/mothership/request/go/file-preview-adapter.ts @@ -29,11 +29,8 @@ import { loadWorkspaceFileTextForPreview, type WorkspaceFilePreviewBase, } from '@/lib/mothership/tools/server/files/file-preview' -import { - findWorkspaceFileRecord, - resolveWorkspaceFileReference, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' +import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { findActiveWorkspaceFilesByReference } from '@/lib/workspace-files/application/list-workspace-files' const logger = createLogger('CopilotFilePreviewAdapter') @@ -115,11 +112,12 @@ async function resolvePreviewTarget(args: { } try { - const { files } = await executeCopilotFileUseCase(args.context, listAllWorkspaceFiles, { - workspaceId: args.workspaceId, - scope: 'active', - }) - const file = findWorkspaceFileRecord(files, args.target.path) + const { files } = await executeCopilotFileUseCase( + args.context, + findActiveWorkspaceFilesByReference, + { workspaceId: args.workspaceId, references: [args.target.path] } + ) + const file = files.get(args.target.path) if (!file) { return args.target } diff --git a/apps/sim/lib/mothership/request/go/stream.test.ts b/apps/sim/lib/mothership/request/go/stream.test.ts index 7568ccd7626..d1742ed5294 100644 --- a/apps/sim/lib/mothership/request/go/stream.test.ts +++ b/apps/sim/lib/mothership/request/go/stream.test.ts @@ -98,18 +98,10 @@ import { createEvent, hasAbortMarker } from '@/lib/mothership/request/session' import { TraceCollector } from '@/lib/mothership/request/trace' import type { ExecutionContext, StreamingContext } from '@/lib/mothership/request/types' -const mockListAllWorkspaceFiles = workspaceFilesListMockFns.mockListAllWorkspaceFiles +const mockFindFilesByReference = workspaceFilesListMockFns.mockFindActiveWorkspaceFilesByReference const resolveWorkspaceFileReferenceMock = workspaceFileManagerMockFns.mockResolveWorkspaceFileReference -workspaceFileManagerMockFns.mockFindWorkspaceFileRecord.mockImplementation( - (files: Array<{ name: string; folderPath?: string | null }>, path: string) => - files.find((file) => { - const normalized = path.replace(/^files\//, '').replaceAll('%20', ' ') - const filePath = file.folderPath ? `${file.folderPath}/${file.name}` : file.name - return filePath === normalized - }) ?? null -) function createSseResponse(events: unknown[]): Response { const payload = events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join('') @@ -181,8 +173,8 @@ describe('copilot go stream helpers', () => { vi.stubGlobal('fetch', vi.fn()) resolveWorkspaceFileReferenceMock.mockReset() resolveWorkspaceFileReferenceMock.mockResolvedValue(null) - mockListAllWorkspaceFiles.mockReset() - mockListAllWorkspaceFiles.mockResolvedValue({ files: [] }) + mockFindFilesByReference.mockReset() + mockFindFilesByReference.mockResolvedValue({ files: new Map() }) changeStoredChatResourcesMock.mockReset() changeStoredChatResourcesMock.mockResolvedValue([]) materializeStreamImageMock.mockReset().mockResolvedValue({ url: '/private-image' }) @@ -577,14 +569,13 @@ describe('copilot go stream helpers', () => { }) it('resolves workflow alias paths to the backing file before streaming previews', async () => { - mockListAllWorkspaceFiles.mockResolvedValue({ - files: [ - { - id: 'changelog-file-1', - name: 'changelog.md', - folderPath: 'workflows/My Workflow', - }, - ], + mockFindFilesByReference.mockResolvedValue({ + files: new Map([ + [ + 'workflows/My%20Workflow/changelog.md', + { id: 'changelog-file-1', name: 'changelog.md', folderPath: 'workflows/My Workflow' }, + ], + ]), }) const workspaceFileCall = createEvent({ @@ -691,12 +682,15 @@ describe('copilot go stream helpers', () => { previewPhase: 'file_preview_complete', fileId: 'changelog-file-1', }) - expect(mockListAllWorkspaceFiles).toHaveBeenCalledWith({ + expect(mockFindFilesByReference).toHaveBeenCalledWith({ principal: expect.objectContaining({ kind: 'delegated', workspaceId: 'workspace-1', }), - input: { workspaceId: 'workspace-1', scope: 'active' }, + input: { + workspaceId: 'workspace-1', + references: ['workflows/My%20Workflow/changelog.md'], + }, }) }) diff --git a/apps/sim/lib/mothership/tools/handlers/function-execute-file-mounts.test.ts b/apps/sim/lib/mothership/tools/handlers/function-execute-file-mounts.test.ts index dee936c49ad..d940ef76062 100644 --- a/apps/sim/lib/mothership/tools/handlers/function-execute-file-mounts.test.ts +++ b/apps/sim/lib/mothership/tools/handlers/function-execute-file-mounts.test.ts @@ -47,7 +47,7 @@ import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-tr const mocks = { ...hoisted, - list: workspaceFilesListMockFns.mockListAllWorkspaceFiles, + find: workspaceFilesListMockFns.mockFindActiveWorkspaceFilesByReference, file: workspaceFileManagerMockFns.mockGetWorkspaceFile, context: workspaceFileManagerMockFns.mockLoadActiveWorkspaceFileContext, buffer: workspaceFileManagerMockFns.mockFetchWorkspaceFileBuffer, @@ -59,9 +59,6 @@ const mocks = { /** Reinstalls the file-manager helpers this suite fixes, after `vi.resetAllMocks()` restores defaults. */ function pinFileManagerHelpers() { - workspaceFileManagerMockFns.mockFindWorkspaceFileRecord.mockImplementation( - (files: { id: string }[], id: string) => files.find((candidate) => candidate.id === id) - ) workspaceFileManagerMockFns.mockGetSandboxWorkspaceFilePath.mockReturnValue( '/home/user/files/source.txt' ) @@ -129,7 +126,7 @@ describe('Mothership file mounts bind content and classification to the same rec pinFileManagerHelpers() resetDbChainMock() mocks.file.mockResolvedValue(file) - mocks.list.mockResolvedValue({ files: [file] }) + mocks.find.mockResolvedValue({ files: new Map([['file', file]]) }) mocks.context.mockResolvedValue({ workspaceId: 'workspace', fileId: 'file', @@ -427,7 +424,7 @@ describe('organization-owned upload mounts', () => { }), input: expect.objectContaining({ chatId: 'chat', reference: 'uploads/upload' }), }) - expect(mocks.list).not.toHaveBeenCalled() + expect(mocks.find).not.toHaveBeenCalled() expect(registry.isComplete()).toBe(true) }) it.each(['missing', 'absence', 'matching-secret'] as const)( @@ -509,6 +506,6 @@ describe('organization-owned upload mounts', () => { registry ) ).rejects.toThrow('Attachment not found') - expect(mocks.list).not.toHaveBeenCalled() + expect(mocks.find).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/mothership/tools/handlers/function-execute.ts b/apps/sim/lib/mothership/tools/handlers/function-execute.ts index 6380c89319d..ce761b5e7d5 100644 --- a/apps/sim/lib/mothership/tools/handlers/function-execute.ts +++ b/apps/sim/lib/mothership/tools/handlers/function-execute.ts @@ -16,6 +16,7 @@ import { MAX_PLAN_REQUIRED } from '@/lib/execution/remote-sandbox/entitlement' import { observeSandboxSessionInputs } from '@/lib/execution/remote-sandbox/execution-observer' import { SANDBOX_INPUT_DIR } from '@/lib/execution/remote-sandbox/sandbox-paths' import type { SandboxFile } from '@/lib/execution/remote-sandbox/types' +import { collectDescendantFolderIdsFrom, indexFolderChildren } from '@/lib/folders/subtree' import { createSandboxMountBudget, MAX_INLINE_MOUNT_FILE_BYTES, @@ -47,7 +48,6 @@ import { import { recordSecretUsage } from '@/lib/secrets/usage/record' import { readTableSnapshot } from '@/lib/table/application/read-table-snapshot' import { - findWorkspaceFileRecord, getSandboxWorkspaceFilePath, parseChatUploadReference, type WorkspaceFileRecord, @@ -55,7 +55,10 @@ import { import { importWorkspaceFileSnapshotProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/vfs/path' import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization' -import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' +import { + findActiveWorkspaceFilesByReference, + queryWorkspaceFilePage, +} from '@/lib/workspace-files/application/list-workspace-files' import { fileOperations } from '@/lib/workspace-files/application/operations' import { readWorkspaceFileMount } from '@/lib/workspace-files/application/read-workspace-file-mount' import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' @@ -191,12 +194,11 @@ function refField(ref: unknown, key: 'path' | 'tableId' | 'sandboxPath'): string * is the honest not-found rather than a hint to go looking elsewhere. */ async function resolveMountableWorkspaceFile( - allFiles: WorkspaceFileRecord[], + listed: WorkspaceFileRecord | null | undefined, filePath: string, workspaceId: string, principal: Principal ): Promise { - const listed = findWorkspaceFileRecord(allFiles, filePath) if (listed) return listed if (parseChatUploadReference(filePath) !== null) { @@ -306,15 +308,18 @@ export async function resolveInputFiles( `Too many input files (${inputFiles.length}). Maximum is ${MAX_MOUNTED_FILES}. Mount fewer files.` ) } - const { files: allFiles } = await listAllWorkspaceFiles.execute({ + const { files: listedFiles } = await findActiveWorkspaceFilesByReference.execute({ principal: filePrincipal, - input: { workspaceId, scope: 'active' }, + input: { + workspaceId, + references: inputFiles.flatMap((fileRef) => refField(fileRef, 'path') ?? []), + }, }) for (const fileRef of inputFiles) { const filePath = refField(fileRef, 'path') if (!filePath) continue const record = await resolveMountableWorkspaceFile( - allFiles, + listedFiles.get(filePath), filePath, workspaceId, filePrincipal @@ -341,10 +346,7 @@ export async function resolveInputFiles( principal: filePrincipal, input: { workspaceId }, }) - const { files: allFiles } = await listAllWorkspaceFiles.execute({ - principal: filePrincipal, - input: { workspaceId, scope: 'active' }, - }) + const childrenByParent = indexFolderChildren(folders) for (const dirRef of inputDirectories) { const dirPath = refField(dirRef, 'path') if (!dirPath) continue @@ -362,13 +364,25 @@ export async function resolveInputFiles( const mountRoot = refField(dirRef, 'sandboxPath') ?? `/home/user/files/${encodeVfsPathSegments(parseWorkspaceFileFolderDisplayPath(folder.path))}` - const descendants = allFiles.filter((file) => { - if (!file.folderPath) return false - return file.folderPath === folder.path || file.folderPath.startsWith(`${folder.path}/`) + const { files: descendants, nextKeys } = await queryWorkspaceFilePage.execute({ + principal: filePrincipal, + input: { + workspaceId, + folderScope: { + folderIds: new Set([ + folder.id, + ...collectDescendantFolderIdsFrom(childrenByParent, folder.id), + ]), + includeRootItems: false, + }, + sortBy: 'uploadedAt', + sortOrder: 'asc', + limit: MAX_MOUNTED_FILES, + }, }) - if (descendants.length > MAX_MOUNTED_FILES) { + if (nextKeys) { throw new Error( - `Input directory contains too many files (${descendants.length}). Maximum is ${MAX_MOUNTED_FILES}. Mount a smaller directory or individual files.` + `Input directory contains more than ${MAX_MOUNTED_FILES} files. Mount a smaller directory or individual files.` ) } logger.info('Mounting workspace directory for run_function', { diff --git a/apps/sim/lib/uploads/contexts/workspace/__integration__/file-names.integration.ts b/apps/sim/lib/uploads/contexts/workspace/__integration__/file-names.integration.ts index d4dc9369f66..043dbf6cfba 100644 --- a/apps/sim/lib/uploads/contexts/workspace/__integration__/file-names.integration.ts +++ b/apps/sim/lib/uploads/contexts/workspace/__integration__/file-names.integration.ts @@ -40,8 +40,11 @@ import { workspaceFileNameFolderCondition, } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { + findListedWorkspaceFiles, + findWorkspaceFileRecord, generateWorkspaceFileKey, getWorkspaceFileByName, + listWorkspaceFiles, resolveWorkspaceFileReference, trackChatUpload, uploadWorkspaceFile, @@ -282,6 +285,52 @@ describe('workspace file names in PostgreSQL', () => { expect(scan).not.toContain('workspace_files_workspace_active_keyset_idx') }) + it('picks the same file from candidate rows as from the whole listing', async () => { + const fixture = await seedWorkspace() + const reports = await createWorkspaceFileFolder({ + workspaceId: fixture.workspaceId, + userId: fixture.aliceId, + name: 'Reports', + }) + const archive = await createWorkspaceFileFolder({ + workspaceId: fixture.workspaceId, + userId: fixture.aliceId, + name: 'Archive', + parentId: reports.id, + }) + const rootDuplicate = await upload(fixture.workspaceId, fixture.aliceId, 'dup.txt', null) + await upload(fixture.workspaceId, fixture.aliceId, 'dup.txt', reports.id) + const nested = await upload(fixture.workspaceId, fixture.aliceId, 'Q1 Plan.csv', archive.id) + const deleted = await upload(fixture.workspaceId, fixture.aliceId, 'gone.txt', null) + await db + .update(workspaceFiles) + .set({ deletedAt: new Date() }) + .where(eq(workspaceFiles.id, deleted.id)) + const listing = await listWorkspaceFiles(fixture.workspaceId) + + const references = [ + 'dup.txt', + 'files/dup.txt', + 'files/Reports/dup.txt', + 'Reports/dup.txt', + 'files/Reports/Archive/Q1%20Plan.csv', + 'Q1 Plan.csv', + nested.id, + `files/${nested.id}`, + rootDuplicate.id, + 'gone.txt', + 'missing.txt', + ] + const batched = await findListedWorkspaceFiles(fixture.workspaceId, references) + for (const reference of references) { + const expected = findWorkspaceFileRecord(listing, reference)?.id + expect(batched.get(reference)?.id, reference).toBe(expected) + const [single] = (await findListedWorkspaceFiles(fixture.workspaceId, [reference])).values() + expect(single?.id, reference).toBe(expected) + } + expect(batched.get('dup.txt')?.id).toBe(rootDuplicate.id) + }) + it('falls back to a short-id suffix after 20 numbered copies, including under concurrency', async () => { const fixture = await seedWorkspace() await upload(fixture.workspaceId, fixture.aliceId, 'page.html') diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index 882ff622cc1..1cc4a6bd13a 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -1811,24 +1811,48 @@ export async function resolveWorkspaceFileReference( const exactReferenceFile = await getWorkspaceFileByExactReference(workspaceId, referenceSegments) if (exactReferenceFile) return exactReferenceFile - const candidates = await listWorkspaceFileReferenceCandidates( - workspaceId, - [fileReference, normalizedReference], - referenceSegments.at(-1) + return (await findListedWorkspaceFiles(workspaceId, [fileReference])).get(fileReference) ?? null +} + +/** + * The live workspace file {@link findWorkspaceFileRecord} would pick for each reference from + * the whole `listWorkspaceFiles` listing, read through one query for the references' candidate + * rows instead of every file the workspace has. Ties on `uploadedAt` break by id, where the + * listing had no order. + */ +export async function findListedWorkspaceFiles( + workspaceId: string, + fileReferences: readonly string[] +): Promise> { + const ids = new Set() + const leafNames = new Set() + for (const reference of fileReferences) { + const segments = normalizeWorkspaceFileReferenceSegments(reference) + ids.add(reference) + ids.add(segments.join('/')) + const leaf = segments.at(-1) + if (leaf !== undefined) leafNames.add(normalizeDisplaySegment(leaf)) + } + const candidates = + fileReferences.length === 0 + ? [] + : await listWorkspaceFileReferenceCandidates(workspaceId, [...ids], [...leafNames]) + return new Map( + fileReferences.map((reference) => [reference, findWorkspaceFileRecord(candidates, reference)]) ) - return findWorkspaceFileRecord(candidates, fileReference) } /** - * The live workspace files {@link findWorkspaceFileRecord} can match for a reference: those - * whose id is one of `ids`, or whose name displays as `leafName`. Each of its rules requires one - * of the two, so it picks the same file from these rows as from the whole workspace — read - * through `workspace_files_workspace_display_name_idx` instead of every file the workspace has. + * The live workspace files {@link findWorkspaceFileRecord} can match for a set of references: + * those whose id is one of `ids`, or whose name displays as one of `leafNames`. Each of its + * rules requires one of the two, so it picks the same file from these rows as from the whole + * workspace — read through `workspace_files_workspace_display_name_idx` instead of every file + * the workspace has. */ async function listWorkspaceFileReferenceCandidates( workspaceId: string, ids: string[], - leafName: string | undefined + leafNames: string[] ): Promise { const rows = await db .select(workspaceFileListColumns) @@ -1838,9 +1862,9 @@ async function listWorkspaceFileReferenceCandidates( workspaceFileScopeCondition(workspaceId, 'active'), or( inArray(workspaceFiles.id, ids), - leafName === undefined + leafNames.length === 0 ? undefined - : eq(displaySegmentKey(workspaceFiles.originalName), normalizeDisplaySegment(leafName)) + : inArray(displaySegmentKey(workspaceFiles.originalName), leafNames) ) ) ) diff --git a/apps/sim/lib/workspace-files/application/list-workspace-files.ts b/apps/sim/lib/workspace-files/application/list-workspace-files.ts index 57a82ed0718..fa210eab7af 100644 --- a/apps/sim/lib/workspace-files/application/list-workspace-files.ts +++ b/apps/sim/lib/workspace-files/application/list-workspace-files.ts @@ -6,6 +6,7 @@ import type { FolderIdScope } from '@/lib/folders/scope' import { collectDescendantFolderIdsFrom, indexFolderChildren } from '@/lib/folders/subtree' import { getWorkspaceShares } from '@/lib/public-shares/share-manager' import { + findListedWorkspaceFiles, listWorkspaceFiles, loadActiveWorkspaceContext, queryWorkspaceFiles, @@ -89,6 +90,25 @@ export const listAllWorkspaceFiles = defineAuthorizedWorkspaceFileUseCase({ }, }) +interface FindActiveWorkspaceFilesByReferenceInput { + workspaceId: string + references: readonly string[] +} + +/** + * The active workspace file a listing-based lookup would pick for each reference (an id, a + * VFS path, or a bare name), under the same list authorization, without reading the listing. + * Workspace files only: chat uploads and owned files are never matched. + */ +export const findActiveWorkspaceFilesByReference = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.list, + resolveContext: ({ input }: { input: FindActiveWorkspaceFilesByReferenceInput }) => + resolveListWorkspaceFileContext(input.workspaceId), + async execute({ input, context }) { + return { files: await findListedWorkspaceFiles(context.workspaceId, input.references) } + }, +}) + export const queryWorkspaceFilePage = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.list, resolveContext: ({ input }: { input: QueryWorkspaceFilePageInput }) => diff --git a/apps/sim/lib/workspaces/__integration__/recently-deleted.integration.ts b/apps/sim/lib/workspaces/__integration__/recently-deleted.integration.ts new file mode 100644 index 00000000000..93c9781673b --- /dev/null +++ b/apps/sim/lib/workspaces/__integration__/recently-deleted.integration.ts @@ -0,0 +1,311 @@ +import { db } from '@sim/db' +import { + copilotChats, + folder, + knowledgeBase, + user, + userTableDefinitions, + workflow, + workspace, + workspaceFiles, +} from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { inArray, sql } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + RECENTLY_DELETED_ITEM_TYPES, + type RecentlyDeletedItem, + type RecentlyDeletedSortColumn, +} from '@/lib/api/contracts/recently-deleted' +import type { CursorKey, ListSortOrder } from '@/lib/api/list-query' +import { listRecentlyDeletedItems } from '@/lib/workspaces/recently-deleted' + +const aliceId = generateId() +const bobId = generateId() +const workspaceId = generateId() +const otherWorkspaceId = generateId() + +const ALL_TYPES = new Set(RECENTLY_DELETED_ITEM_TYPES) + +/** Bound as PostgreSQL text so fixtures keep their microseconds. */ +function at(timestamp: string) { + return sql`${timestamp}::timestamp` +} + +const ids = { + workflowOld: generateId(), + workflowNew: generateId(), + workflowActive: generateId(), + workflowFolder: generateId(), + knowledgeFolder: generateId(), + table: generateId(), + knowledge: generateId(), + fileA: generateId(), + fileB: generateId(), + knowledgeContextFile: generateId(), + fileFolder: generateId(), + aliceChat: generateId(), + aliceCopilotChat: generateId(), + bobChat: generateId(), + otherWorkspaceWorkflow: generateId(), +} + +/** Every row the trash should list for Alice, as `[id, name, deletedAt]`. */ +const VISIBLE = [ + [ids.workflowOld, 'Alpha flow', '2026-09-01T10:00:00.000Z'], + [ids.fileFolder, 'Docs', '2026-09-01T10:00:00.000Z'], + [ids.workflowFolder, 'Folder A', '2026-09-02T10:00:00.000Z'], + [ids.knowledgeFolder, 'KB folder', '2026-09-02T10:00:00.000Z'], + [ids.workflowNew, 'beta 100% done', '2026-09-03T10:00:00.123Z'], + [ids.table, 'Orders', '2026-09-03T10:00:00.123Z'], + [ids.knowledge, 'Handbook', '2026-09-04T10:00:00.000Z'], + [ids.fileA, 'same.txt', '2026-09-05T10:00:00.000Z'], + [ids.fileB, 'same.txt', '2026-09-05T10:00:00.000Z'], + [ids.aliceChat, 'New chat', '2026-09-06T10:00:00.000Z'], +] as const + +async function walk( + sortBy: RecentlyDeletedSortColumn, + sortOrder: ListSortOrder, + limit: number, + search?: string +): Promise { + const items: RecentlyDeletedItem[] = [] + let cursorKeys: CursorKey[] | undefined + for (let page = 0; page < 50; page++) { + const result = await listRecentlyDeletedItems({ + workspaceId, + types: ALL_TYPES, + chatUserId: aliceId, + search, + sortBy, + sortOrder, + limit, + cursorKeys, + }) + items.push(...result.items) + if (!result.nextCursorKeys) return items + cursorKeys = result.nextCursorKeys + } + throw new Error('pagination did not terminate') +} + +describe('Recently Deleted listing against PostgreSQL', () => { + beforeAll(async () => { + const now = new Date() + await db.insert(user).values( + [aliceId, bobId].map((id) => ({ + id, + name: 'Recently deleted fixture', + email: `${id}@fixture.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + })) + ) + await db.insert(workspace).values( + [workspaceId, otherWorkspaceId].map((id) => ({ + id, + name: 'Recently deleted fixture', + ownerId: aliceId, + billedAccountUserId: aliceId, + })) + ) + const workflowRow = (id: string, wsId: string, name: string, archivedAt: string | null) => ({ + id, + userId: aliceId, + workspaceId: wsId, + name, + lastSynced: now, + createdAt: now, + updatedAt: now, + archivedAt: archivedAt === null ? null : at(archivedAt), + }) + await db + .insert(workflow) + .values([ + workflowRow(ids.workflowOld, workspaceId, 'Alpha flow', '2026-09-01 10:00:00.000400'), + workflowRow(ids.workflowNew, workspaceId, 'beta 100% done', '2026-09-03 10:00:00.123001'), + workflowRow(ids.workflowActive, workspaceId, 'Active flow', null), + workflowRow(ids.otherWorkspaceWorkflow, otherWorkspaceId, 'Elsewhere', '2026-09-09'), + ]) + await db.insert(folder).values([ + { + id: ids.workflowFolder, + resourceType: 'workflow', + name: 'Folder A', + userId: aliceId, + workspaceId, + deletedAt: at('2026-09-02 10:00:00'), + }, + { + id: ids.knowledgeFolder, + resourceType: 'knowledge_base', + name: 'KB folder', + userId: aliceId, + workspaceId, + deletedAt: at('2026-09-02 10:00:00'), + }, + { + id: ids.fileFolder, + resourceType: 'file', + name: 'Docs', + userId: aliceId, + workspaceId, + deletedAt: at('2026-09-01 10:00:00.000400'), + }, + ]) + await db.insert(userTableDefinitions).values({ + id: ids.table, + workspaceId, + name: 'Orders', + schema: { columns: [] }, + createdBy: aliceId, + archivedAt: at('2026-09-03 10:00:00.123999'), + }) + await db.insert(knowledgeBase).values({ + id: ids.knowledge, + userId: aliceId, + workspaceId, + name: 'Handbook', + deletedAt: at('2026-09-04 10:00:00'), + }) + const fileRow = (id: string, context: string, folderId: string | null, deletedAt: string) => ({ + id, + key: `fixture/${id}`, + userId: aliceId, + workspaceId, + folderId, + context, + originalName: context === 'workspace' ? 'same.txt' : 'kb-source.pdf', + contentType: 'text/plain', + deletedAt: at(deletedAt), + }) + await db + .insert(workspaceFiles) + .values([ + fileRow(ids.fileA, 'workspace', null, '2026-09-05 10:00:00'), + fileRow(ids.fileB, 'workspace', ids.fileFolder, '2026-09-05 10:00:00'), + fileRow(ids.knowledgeContextFile, 'knowledge-base', null, '2026-09-07 10:00:00'), + ]) + await db.insert(copilotChats).values([ + { + id: ids.aliceChat, + userId: aliceId, + workspaceId, + type: 'mothership', + deletedAt: at('2026-09-06 10:00:00'), + }, + { + id: ids.aliceCopilotChat, + userId: aliceId, + workspaceId, + type: 'copilot', + title: 'Copilot chat', + deletedAt: at('2026-09-08 10:00:00'), + }, + { + id: ids.bobChat, + userId: bobId, + workspaceId, + type: 'mothership', + title: 'Bob chat', + deletedAt: at('2026-09-08 10:00:00'), + }, + ]) + }) + + afterAll(async () => { + await db.delete(workspace).where(inArray(workspace.id, [workspaceId, otherWorkspaceId])) + await db.delete(user).where(inArray(user.id, [aliceId, bobId])) + await db.$client.end() + }) + + it('lists every visible archived row across types and scopes, newest first', async () => { + const items = await walk('deleted', 'desc', 100) + expect(new Set(items.map((item) => item.id))).toEqual(new Set(VISIBLE.map(([id]) => id))) + expect(items[0]?.id).toBe(ids.aliceChat) + for (const [id, name, deletedAt] of VISIBLE) { + expect(items.find((item) => item.id === id)).toMatchObject({ name, deletedAt }) + } + expect(items.find((item) => item.id === ids.workflowFolder)?.parentId).toBeNull() + }) + + it.each([ + ['deleted', 'desc'], + ['deleted', 'asc'], + ['name', 'asc'], + ['name', 'desc'], + ['type', 'asc'], + ['type', 'desc'], + ] as const)( + 'pages through %s %s one row at a time without skipping or repeating', + async (sortBy, sortOrder) => { + const whole = await walk(sortBy, sortOrder, 100) + expect(whole).toHaveLength(VISIBLE.length) + for (const limit of [1, 2, 3]) { + expect(await walk(sortBy, sortOrder, limit)).toEqual(whole) + } + } + ) + + it('orders rows tied on deletion time by name, then id, ascending either way', async () => { + for (const sortOrder of ['asc', 'desc'] as const) { + const order = (await walk('deleted', sortOrder, 100)).map((item) => item.id) + expect(order.indexOf(ids.workflowFolder)).toBeLessThan(order.indexOf(ids.knowledgeFolder)) + expect(order.indexOf(ids.workflowOld)).toBeLessThan(order.indexOf(ids.fileFolder)) + const [firstSame, secondSame] = [ids.fileA, ids.fileB].sort( + (a, b) => order.indexOf(a) - order.indexOf(b) + ) + expect(order.indexOf(firstSame) + 1).toBe(order.indexOf(secondSame)) + } + }) + + it('keeps microsecond-apart rows of different types on the right side of a page boundary', async () => { + const items = await walk('deleted', 'desc', 1) + const order = items.map((item) => item.id) + expect(order.indexOf(ids.table)).toBe(order.indexOf(ids.workflowNew) - 1) + }) + + it('searches names case-insensitively and literally', async () => { + expect((await walk('deleted', 'desc', 2, 'ALPHA')).map((item) => item.id)).toEqual([ + ids.workflowOld, + ]) + expect((await walk('deleted', 'desc', 2, '100%')).map((item) => item.id)).toEqual([ + ids.workflowNew, + ]) + expect(await walk('deleted', 'desc', 2, '_')).toEqual([]) + expect((await walk('name', 'asc', 1, 'new chat')).map((item) => item.id)).toEqual([ + ids.aliceChat, + ]) + }) + + it('narrows to the requested types and leaves chats out without a viewer', async () => { + const page = await listRecentlyDeletedItems({ + workspaceId, + types: new Set(['table', 'table_folder', 'chat']), + chatUserId: null, + sortBy: 'deleted', + sortOrder: 'desc', + limit: 10, + }) + expect(page.items.map((item) => item.id)).toEqual([ids.table]) + expect(page.nextCursorKeys).toBeNull() + }) + + it('refuses a cursor that does not fit the sort', async () => { + for (const cursorKeys of [['2026-09-01T00:00:00.000Z', 'a', 'b'], ['only-one'], [1, 'a']]) { + await expect( + listRecentlyDeletedItems({ + workspaceId, + types: ALL_TYPES, + chatUserId: aliceId, + sortBy: 'deleted', + sortOrder: 'desc', + limit: 10, + cursorKeys, + }) + ).rejects.toMatchObject({ code: 'validation' }) + } + }) +}) diff --git a/apps/sim/lib/workspaces/application/list-recently-deleted.ts b/apps/sim/lib/workspaces/application/list-recently-deleted.ts new file mode 100644 index 00000000000..c93fef5698a --- /dev/null +++ b/apps/sim/lib/workspaces/application/list-recently-deleted.ts @@ -0,0 +1,108 @@ +import { + RECENTLY_DELETED_TYPES_BY_FILTER, + type RecentlyDeletedFilter, + type RecentlyDeletedItem, + type RecentlyDeletedSortColumn, +} from '@/lib/api/contracts/recently-deleted' +import type { CursorKey, ListSortOrder } from '@/lib/api/list-query' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application/authorized-workspace-use-case' +import { + defineWorkspaceOperation, + type WorkspaceOperation, +} from '@/lib/core/application/workspace-operation' +import { isChatEnabled } from '@/lib/core/config/env-flags' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import type { StaticPermissionGroupCapability } from '@/lib/permission-groups/capabilities' +import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' +import { tableOperations } from '@/lib/table/application/operations' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' +import { listRecentlyDeletedItems } from '@/lib/workspaces/recently-deleted' + +type RecentlyDeletedType = RecentlyDeletedItem['type'] + +export const recentlyDeletedOperations = { + // permission-group-exempt: spans several resource types, so the use case applies each type's own capability to its rows instead + list: defineWorkspaceOperation({ + id: 'workspaces.recently_deleted.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + capability: 'none', + principalKinds: ['session'], + }), +} as const + +/** + * Each type's own list operation, whose capability gates the type here too. A type whose + * capability the viewer's permission group withholds is left out of the page, folders with it. + */ +const LIST_OPERATION_BY_TYPE: Partial> = { + table: tableOperations.list, + table_folder: tableOperations.list, + knowledge: knowledgeOperations.list, + knowledge_folder: knowledgeOperations.list, + file: fileOperations.list, + workspace_folder: fileOperations.list, +} + +function capabilityFor(type: RecentlyDeletedType): StaticPermissionGroupCapability | undefined { + const capability = LIST_OPERATION_BY_TYPE[type]?.capability + return capability === 'none' ? undefined : capability +} + +export interface ListRecentlyDeletedInput { + workspaceId: string + filter: RecentlyDeletedFilter + search?: string + sortBy: RecentlyDeletedSortColumn + sortOrder: ListSortOrder + limit: number + cursorKeys?: CursorKey[] +} + +/** + * One page of a workspace's Recently Deleted view across every resource type: workspace + * read access, each type's own capability, and chats limited to the viewer's own — the + * same rules the per-type archived lists apply. + */ +export const listRecentlyDeleted = defineAuthorizedWorkspaceUseCase({ + operation: recentlyDeletedOperations.list, + resolveContext: ({ input }: { input: ListRecentlyDeletedInput }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + authorizationOptions: {}, + async execute({ principal, input, context }) { + const requested = RECENTLY_DELETED_TYPES_BY_FILTER[input.filter].filter( + (type) => type !== 'chat' || isChatEnabled + ) + const withheld = new Set() + await Promise.all( + [...new Set(requested.flatMap((type) => capabilityFor(type) ?? []))].map( + async (capability) => { + const isWithheld = await isWorkspaceCapabilityWithheld( + principal.userId, + context.workspaceId, + capability, + context.workspaceOrganizationId + ) + if (isWithheld) withheld.add(capability) + } + ) + ) + const types = new Set( + requested.filter((type) => { + const capability = capabilityFor(type) + return !capability || !withheld.has(capability) + }) + ) + return listRecentlyDeletedItems({ + workspaceId: context.workspaceId, + types, + chatUserId: principal.userId, + search: input.search, + sortBy: input.sortBy, + sortOrder: input.sortOrder, + limit: input.limit, + cursorKeys: input.cursorKeys, + }) + }, +}) diff --git a/apps/sim/lib/workspaces/recently-deleted.ts b/apps/sim/lib/workspaces/recently-deleted.ts new file mode 100644 index 00000000000..8131a95c16e --- /dev/null +++ b/apps/sim/lib/workspaces/recently-deleted.ts @@ -0,0 +1,296 @@ +import { db } from '@sim/db' +import { + copilotChats, + folder, + knowledgeBase, + userTableDefinitions, + workflow, + workspaceFiles, +} from '@sim/db/schema' +import { and, eq, inArray, isNotNull, type SQL, sql } from 'drizzle-orm' +import type { + RecentlyDeletedItem, + RecentlyDeletedSortColumn, +} from '@/lib/api/contracts/recently-deleted' +import { + type CursorKey, + type KeysetKey, + keysetOrderBy, + keysetPage, + type ListSortOrder, + preciseTimestampKey, + preciseTimestampText, + resumeKeyset, + searchFilter, + textKey, +} from '@/lib/api/list-query' + +type RecentlyDeletedType = RecentlyDeletedItem['type'] + +export interface RecentlyDeletedPage { + items: RecentlyDeletedItem[] + nextCursorKeys: CursorKey[] | null +} + +export interface ListRecentlyDeletedOptions { + workspaceId: string + /** The types to include. Chats additionally require `chatUserId`. */ + types: ReadonlySet + /** Chats are private: only this user's own deleted chats are listed. */ + chatUserId: string | null + search?: string + sortBy: RecentlyDeletedSortColumn + sortOrder: ListSortOrder + limit: number + cursorKeys?: CursorKey[] +} + +/** One listed row as the union projects it. */ +interface RecentlyDeletedRow extends Record { + id: string + name: string + type: RecentlyDeletedType + deleted_at_key: string + parent_id: string | null +} + +/** The columns every source projects, as the expressions the sort and search read. */ +interface RowExpressions { + id: SQL + name: SQL + deletedAt: SQL + type: SQL + parentId: SQL +} + +interface DeletedSource { + table: SQL + row: RowExpressions + where: SQL | undefined +} + +const NO_PARENT = sql`null::text` + +/** Folder resource types, by the Recently Deleted type their rows list as. */ +const FOLDER_TYPES = { + folder: 'workflow', + table_folder: 'table', + knowledge_folder: 'knowledge_base', + workspace_folder: 'file', +} as const satisfies Partial< + Record +> + +function isFolderType(type: RecentlyDeletedType): type is keyof typeof FOLDER_TYPES { + return type in FOLDER_TYPES +} + +/** A source whose rows are all one type. */ +function singleTypeSource( + type: RecentlyDeletedType, + table: SQL, + row: Pick, + where: SQL | undefined +): [RecentlyDeletedType, DeletedSource] { + return [type, { table, row: { ...row, type: sql`${type}::text`, parentId: NO_PARENT }, where }] +} + +/** + * Each requested type's archived rows, with exactly the predicate its own archived list + * applies (`listWorkflowsForUser`, `listFoldersForWorkspace`, `listTables`, + * `getWorkspaceKnowledgeBases`, `listWorkspaceFiles`, `listWorkspaceFileFolders`, + * `listMothershipChats`), so the two never disagree about what is in the trash. + * + * Every folder type reads as one source: they share the folder table and its + * `(workspace_id, deleted_at)` index, which one scan serves instead of one per type. + */ +function deletedSources(options: ListRecentlyDeletedOptions): DeletedSource[] { + const { workspaceId, chatUserId, types } = options + const singles = new Map([ + singleTypeSource( + 'workflow', + sql`${workflow}`, + { + id: sql`${workflow.id}`, + name: sql`${workflow.name}`, + deletedAt: sql`${workflow.archivedAt}`, + }, + and(eq(workflow.workspaceId, workspaceId), isNotNull(workflow.archivedAt)) + ), + singleTypeSource( + 'table', + sql`${userTableDefinitions}`, + { + id: sql`${userTableDefinitions.id}`, + name: sql`${userTableDefinitions.name}`, + deletedAt: sql`${userTableDefinitions.archivedAt}`, + }, + and( + eq(userTableDefinitions.workspaceId, workspaceId), + isNotNull(userTableDefinitions.archivedAt) + ) + ), + singleTypeSource( + 'knowledge', + sql`${knowledgeBase}`, + { + id: sql`${knowledgeBase.id}`, + name: sql`${knowledgeBase.name}`, + deletedAt: sql`${knowledgeBase.deletedAt}`, + }, + and(eq(knowledgeBase.workspaceId, workspaceId), isNotNull(knowledgeBase.deletedAt)) + ), + singleTypeSource( + 'file', + sql`${workspaceFiles}`, + { + id: sql`${workspaceFiles.id}`, + name: sql`${workspaceFiles.originalName}`, + deletedAt: sql`${workspaceFiles.deletedAt}`, + }, + and( + eq(workspaceFiles.workspaceId, workspaceId), + eq(workspaceFiles.context, 'workspace'), + isNotNull(workspaceFiles.deletedAt) + ) + ), + ]) + if (chatUserId) { + singles.set( + ...singleTypeSource( + 'chat', + sql`${copilotChats}`, + { + id: sql`${copilotChats.id}::text`, + name: sql`coalesce(${copilotChats.title}, 'New chat')`, + deletedAt: sql`${copilotChats.deletedAt}`, + }, + and( + eq(copilotChats.userId, chatUserId), + eq(copilotChats.workspaceId, workspaceId), + eq(copilotChats.type, 'mothership'), + isNotNull(copilotChats.deletedAt) + ) + ) + ) + } + + const sources = [...types].flatMap((type) => singles.get(type) ?? []) + const folderTypes = [...types].filter(isFolderType) + if (folderTypes.length > 0) { + sources.push({ + table: sql`${folder}`, + row: { + id: sql`${folder.id}`, + name: sql`${folder.name}`, + deletedAt: sql`${folder.deletedAt}`, + type: sql`(case ${sql.join( + Object.entries(FOLDER_TYPES).map( + ([type, resourceType]) => + sql`when ${folder.resourceType} = ${resourceType} then ${type}` + ), + sql` ` + )} end)::text`, + parentId: sql`${folder.parentId}`, + }, + where: and( + eq(folder.workspaceId, workspaceId), + inArray( + folder.resourceType, + folderTypes.map((type) => FOLDER_TYPES[type]) + ), + isNotNull(folder.deletedAt) + ), + }) + } + return sources +} + +/** + * The requested column first, then name and id ascending whichever way the primary runs, + * so rows tied on type or deletion time keep a stable A→Z order. + */ +function sortKeys( + sortBy: RecentlyDeletedSortColumn, + row: Pick +): KeysetKey[] { + const name = textKey(row.name, (r: RecentlyDeletedRow) => r.name) + const idTiebreak = { + ...textKey(row.id, (r: RecentlyDeletedRow) => r.id), + direction: 'asc' as const, + } + switch (sortBy) { + case 'deleted': + return [ + preciseTimestampKey(row.deletedAt, (r: RecentlyDeletedRow) => r.deleted_at_key), + { ...name, direction: 'asc' }, + idTiebreak, + ] + case 'name': + return [name, idTiebreak] + case 'type': + return [ + textKey(row.type, (r: RecentlyDeletedRow) => r.type), + { ...name, direction: 'asc' }, + idTiebreak, + ] + } +} + +/** The union's output columns, which the outer `ORDER BY` reads. */ +const UNIONED_ROW = { + id: sql`id`, + name: sql`name`, + deletedAt: sql`deleted_at`, + type: sql`type`, +} + +/** + * One page of a workspace's Recently Deleted rows across every requested type, ordered + * and searched in SQL. + * + * Each source contributes at most `limit + 1` rows already in page order, served by its + * `(workspace_id, deleted)` partial index on the default sort, and the union is ordered + * once more and cut to the page — so a page reads a bounded number of rows however much + * the trash holds, and only the columns the list renders. + */ +export async function listRecentlyDeletedItems( + options: ListRecentlyDeletedOptions +): Promise { + const outerKeys = sortKeys(options.sortBy, UNIONED_ROW) + // A cursor that does not fit the sort is a 400 even when no type is requested. + resumeKeyset(outerKeys, options.cursorKeys, options.sortOrder) + const sources = deletedSources(options) + if (sources.length === 0) return { items: [], nextCursorKeys: null } + + const pageSize = options.limit + 1 + const branches = sources.map(({ table, row, where }) => { + const keys = sortKeys(options.sortBy, row) + const filter = and( + where, + searchFilter(row.name, options.search), + resumeKeyset(keys, options.cursorKeys, options.sortOrder) + ) + const order = sql.join(keysetOrderBy(keys, options.sortOrder), sql`, `) + return sql`(select ${row.id} as id, ${row.name} as name, ${row.deletedAt} as deleted_at, ${row.type} as type, ${row.parentId} as parent_id from ${table} where ${filter} order by ${order} limit ${pageSize})` + }) + + const rows = await db.execute(sql` + select id, name, type, parent_id, + ${preciseTimestampText(UNIONED_ROW.deletedAt)} as deleted_at_key + from (${sql.join(branches, sql` union all `)}) as deleted_items + order by ${sql.join(keysetOrderBy(outerKeys, options.sortOrder), sql`, `)} + limit ${pageSize}`) + + const { data, nextCursorKeys } = keysetPage(outerKeys, [...rows], options.limit) + return { + items: data.map((row) => ({ + id: row.id, + name: row.name, + type: row.type, + deletedAt: `${row.deleted_at_key.slice(0, 23)}Z`, + parentId: row.parent_id, + })), + nextCursorKeys, + } +} diff --git a/packages/testing/src/mocks/mothership-chat-lifecycle.mock.ts b/packages/testing/src/mocks/mothership-chat-lifecycle.mock.ts index e1bde38aa1d..ff2a418b067 100644 --- a/packages/testing/src/mocks/mothership-chat-lifecycle.mock.ts +++ b/packages/testing/src/mocks/mothership-chat-lifecycle.mock.ts @@ -14,6 +14,7 @@ import { vi } from 'vitest' export const mothershipChatLifecycleMockFns = { mockLoadCopilotChatMessages: vi.fn(), mockLoadChatMcpServerIds: vi.fn(), + mockLoadChatFileAttachmentsNamed: vi.fn(), mockGetAccessibleCopilotChatAuth: vi.fn(), mockGetAccessibleCopilotChatForCancellation: vi.fn(), mockGetAccessibleCopilotChat: vi.fn(), @@ -32,6 +33,7 @@ export const mothershipChatLifecycleMockFns = { export const mothershipChatLifecycleMock = { loadCopilotChatMessages: mothershipChatLifecycleMockFns.mockLoadCopilotChatMessages, loadChatMcpServerIds: mothershipChatLifecycleMockFns.mockLoadChatMcpServerIds, + loadChatFileAttachmentsNamed: mothershipChatLifecycleMockFns.mockLoadChatFileAttachmentsNamed, getAccessibleCopilotChatAuth: mothershipChatLifecycleMockFns.mockGetAccessibleCopilotChatAuth, getAccessibleCopilotChatForCancellation: mothershipChatLifecycleMockFns.mockGetAccessibleCopilotChatForCancellation, diff --git a/packages/testing/src/mocks/workspace-files-list.mock.ts b/packages/testing/src/mocks/workspace-files-list.mock.ts index 2715538dc5a..124fd3613fc 100644 --- a/packages/testing/src/mocks/workspace-files-list.mock.ts +++ b/packages/testing/src/mocks/workspace-files-list.mock.ts @@ -2,7 +2,7 @@ import { vi } from 'vitest' /** * The real `fileOperations.list` operation from `@/lib/workspace-files/application/operations`, - * which all three list use cases share. + * which every list use case shares. */ const FILES_LIST_OPERATION = { id: 'files.list', @@ -42,6 +42,8 @@ export const workspaceFilesListMockFns = { mockQueryWorkspaceFilePageAuthorize: vi.fn(), mockListWorkspaceFilesInFolderScope: vi.fn(), mockListWorkspaceFilesInFolderScopeAuthorize: vi.fn(), + mockFindActiveWorkspaceFilesByReference: vi.fn(), + mockFindActiveWorkspaceFilesByReferenceAuthorize: vi.fn(), } const fns = workspaceFilesListMockFns @@ -74,4 +76,10 @@ export const workspaceFilesListMock = { authorize: fns.mockListWorkspaceFilesInFolderScopeAuthorize, execute: fns.mockListWorkspaceFilesInFolderScope, }, + findActiveWorkspaceFilesByReference: { + operation: FILES_LIST_OPERATION, + delegationAudience: WORKSPACE_FILES_DELEGATION_AUDIENCE, + authorize: fns.mockFindActiveWorkspaceFilesByReferenceAuthorize, + execute: fns.mockFindActiveWorkspaceFilesByReference, + }, } diff --git a/scripts/check-unused-exports.baseline.json b/scripts/check-unused-exports.baseline.json index b355e2f80dd..8225d5e84e9 100644 --- a/scripts/check-unused-exports.baseline.json +++ b/scripts/check-unused-exports.baseline.json @@ -572,8 +572,6 @@ "apps/sim/app/workspace/[workspaceId]/settings/components/mothership/search-params.ts#MOTHERSHIP_ENVIRONMENTS", "apps/sim/app/workspace/[workspaceId]/settings/components/mothership/search-params.ts#MOTHERSHIP_TABS", "apps/sim/app/workspace/[workspaceId]/settings/components/mothership/search-params.ts#MothershipEnvironmentParam", - "apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params.ts#RECENTLY_DELETED_SORT_COLUMNS", - "apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params.ts#RECENTLY_DELETED_TABS", "apps/sim/app/workspace/[workspaceId]/settings/components/settings-header/settings-header.tsx#SettingsBackAction", "apps/sim/app/workspace/[workspaceId]/settings/components/settings-header/settings-header.tsx#SettingsHeaderConfig", "apps/sim/app/workspace/[workspaceId]/settings/components/settings-header/settings-header.tsx#SettingsHeaderSearch",