From c6b1ba35ba91b8fd8e5ada3f7fdc996e6471602e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 8 Aug 2026 10:52:45 -0700 Subject: [PATCH] fix(files): read the Files prefetch from the data layer and bound invitation previews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two unrelated load-time fixes. On a hard refresh of /files the folders painted first and the files a beat later. Both are prefetched and hydrated together, so the files entry was not reaching the client. Each read went to its own route over an internal HTTP request; prefetchQuery swallows a rejection and shouldDehydrateQuery drops the errored entry, so a failure there silently shipped a page with that list missing, and the files read is the heavier of the two. Those two reads now call the data layer. Note the staging logs show no errors from that route, so this removes the failure mode without proving it was the one firing — the request it drops from the render path, and the shape fix below, stand on their own. listWorkspaceFilesWithShares is shared by the route and the prefetch and shapes its result through the route contract's response schema. listWorkspaceFiles returns contentUpdatedAt, which the schema neither declares nor passes through, so the prefetch was caching a field a client fetch never has and that vanished on the next refetch. The reads carry no authorization of their own now that they bypass the route, so the prefetch proves the viewer first. It reuses the layout's cached host-context lookup rather than re-deriving the permission, so the gate costs no extra queries. Separately, GET /api/invitations computed join previews in a serial loop and each preview issues up to three queries of its own, putting all of them on the critical path of the workspace switcher opening. Bounded with mapWithConcurrency; the mapper was already total, which is what that helper requires. --- apps/sim/app/api/invitations/route.test.ts | 108 ++++++++++++++++++ apps/sim/app/api/invitations/route.ts | 37 +++--- .../app/api/workspaces/[id]/files/route.ts | 20 +--- .../workspace/[workspaceId]/files/page.tsx | 7 +- .../workspace/[workspaceId]/files/prefetch.ts | 36 +++--- .../lib/prefetch-internal-fetch.ts | 12 +- .../[workspaceId]/lib/prefetch.test.ts | 93 ++++++++++----- apps/sim/lib/workspace-files/queries.test.ts | 75 ++++++++++++ apps/sim/lib/workspace-files/queries.ts | 26 +++++ 9 files changed, 332 insertions(+), 82 deletions(-) create mode 100644 apps/sim/app/api/invitations/route.test.ts create mode 100644 apps/sim/lib/workspace-files/queries.test.ts create mode 100644 apps/sim/lib/workspace-files/queries.ts diff --git a/apps/sim/app/api/invitations/route.test.ts b/apps/sim/app/api/invitations/route.test.ts new file mode 100644 index 00000000000..4fa986c1603 --- /dev/null +++ b/apps/sim/app/api/invitations/route.test.ts @@ -0,0 +1,108 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetInvitationJoinPreview, mockGetSession, mockListPendingInvitationsForEmail } = + vi.hoisted(() => ({ + mockGetInvitationJoinPreview: vi.fn(), + mockGetSession: vi.fn(), + mockListPendingInvitationsForEmail: vi.fn(), + })) + +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mockGetSession, +})) + +vi.mock('@/lib/invitations/core', () => ({ + getInvitationJoinPreview: mockGetInvitationJoinPreview, + listPendingInvitationsForEmail: mockListPendingInvitationsForEmail, +})) + +import { GET } from '@/app/api/invitations/route' + +function invitation(id: string) { + return { + id, + kind: 'organization', + email: 'invitee@example.com', + organizationId: 'org-1', + organizationName: 'Org', + membershipIntent: 'member', + role: 'member', + status: 'pending', + expiresAt: new Date('2026-02-01T00:00:00.000Z'), + createdAt: new Date('2026-01-01T00:00:00.000Z'), + inviterName: 'Ada', + inviterEmail: 'ada@example.com', + grants: [{ workspaceId: 'ws-1', workspaceName: 'WS', permission: 'read' }], + } +} + +describe('GET /api/invitations', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ + user: { id: 'user-1', email: 'invitee@example.com' }, + }) + }) + + it('pairs each row with its own preview, in order', async () => { + mockListPendingInvitationsForEmail.mockResolvedValue(['a', 'b', 'c'].map(invitation)) + mockGetInvitationJoinPreview.mockImplementation(async (_userId, inv) => ({ for: inv.id })) + + const { invitations } = await (await GET(createMockRequest('GET'))).json() + + expect(invitations.map((i: { id: string }) => i.id)).toEqual(['a', 'b', 'c']) + expect(invitations.map((i: { joinPreview: unknown }) => i.joinPreview)).toEqual([ + { for: 'a' }, + { for: 'b' }, + { for: 'c' }, + ]) + }) + + /** + * The preview is disclosure-only, so one failing row degrades to `null` rather than hiding + * the invitation — which is what makes the bounded mapper safe, since it fails + * all-or-nothing on a throwing mapper. + */ + it('degrades a failing preview to null without dropping the invitation', async () => { + mockListPendingInvitationsForEmail.mockResolvedValue(['a', 'b'].map(invitation)) + mockGetInvitationJoinPreview.mockImplementation(async (_userId, inv) => { + if (inv.id === 'a') throw new Error('preview blew up') + return { for: inv.id } + }) + + const { invitations } = await (await GET(createMockRequest('GET'))).json() + + expect(invitations).toHaveLength(2) + expect(invitations[0].joinPreview).toBeNull() + expect(invitations[1].joinPreview).toEqual({ for: 'b' }) + }) + + /** + * Each preview issues several queries of its own, so the fan-out stays bounded rather than + * holding one pooled connection per pending invitation. + */ + it('runs previews concurrently, up to a bound', async () => { + mockListPendingInvitationsForEmail.mockResolvedValue( + Array.from({ length: 12 }, (_, i) => invitation(`inv-${i}`)) + ) + let inFlight = 0 + let peak = 0 + mockGetInvitationJoinPreview.mockImplementation(async () => { + inFlight++ + peak = Math.max(peak, inFlight) + await Promise.resolve() + inFlight-- + return null + }) + + await GET(createMockRequest('GET')) + + expect(mockGetInvitationJoinPreview).toHaveBeenCalledTimes(12) + expect(peak).toBe(4) + }) +}) diff --git a/apps/sim/app/api/invitations/route.ts b/apps/sim/app/api/invitations/route.ts index 106a7177799..11b12fbced3 100644 --- a/apps/sim/app/api/invitations/route.ts +++ b/apps/sim/app/api/invitations/route.ts @@ -2,11 +2,15 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import type { MyInvitation } from '@/lib/api/contracts/invitations' import { getSession } from '@/lib/auth' +import { mapWithConcurrency } from '@/lib/core/utils/concurrency' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getInvitationJoinPreview, listPendingInvitationsForEmail } from '@/lib/invitations/core' const logger = createLogger('MyInvitationsAPI') +/** Caps how many pooled connections one request can hold; a list is a handful of rows. */ +const INVITATION_PREVIEW_CONCURRENCY = 4 + /** * Pending invitations addressed to the session's email — the invitee-facing * list behind the workspace switcher's Invitations section. Acceptance is @@ -29,24 +33,25 @@ export const GET = withRouteHandler(async () => { * Disclosure-only, so a preview failure degrades to `null` (the client * shows a generic notice) rather than hiding the invitation. * - * Sequential on purpose: each preview issues several queries, and this - * endpoint is hit whenever the workspace switcher opens. Fanning them out - * with `Promise.all` would hold one pooled connection per pending - * invitation for the length of the slowest one. The list is a handful of - * rows, so the added latency is not worth the pool pressure. + * Each preview issues up to three queries of its own, so a serial loop put + * every one of them on the critical path of the switcher opening. The mapper + * must stay total — `mapWithConcurrency` fails the whole batch on a throw. */ - const previews: Array> | null> = [] - for (const inv of invitations) { - try { - previews.push(await getInvitationJoinPreview(session.user.id, inv)) - } catch (previewError) { - logger.warn('Failed to compute join preview for pending invitation', { - invitationId: inv.id, - error: previewError, - }) - previews.push(null) + const previews = await mapWithConcurrency( + invitations, + INVITATION_PREVIEW_CONCURRENCY, + async (inv) => { + try { + return await getInvitationJoinPreview(session.user.id, inv) + } catch (previewError) { + logger.warn('Failed to compute join preview for pending invitation', { + invitationId: inv.id, + error: previewError, + }) + return null + } } - } + ) return NextResponse.json({ invitations: invitations.map( diff --git a/apps/sim/app/api/workspaces/[id]/files/route.ts b/apps/sim/app/api/workspaces/[id]/files/route.ts index 02e122c9b43..0d59f2f8497 100644 --- a/apps/sim/app/api/workspaces/[id]/files/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/route.ts @@ -16,14 +16,10 @@ import { } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { getWorkspaceShares } from '@/lib/public-shares/share-manager' -import { - FileConflictError, - listWorkspaceFiles, - uploadWorkspaceFile, -} from '@/lib/uploads/contexts/workspace' +import { FileConflictError, uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { MAX_WORKSPACE_FORMDATA_FILE_SIZE } from '@/lib/uploads/shared/types' +import { listWorkspaceFilesWithShares } from '@/lib/workspace-files/queries' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' @@ -73,15 +69,11 @@ export const GET = withRouteHandler( } const { scope } = queryResult.data - const files = await listWorkspaceFiles(workspaceId, { scope }) - - const shares = await getWorkspaceShares('file', workspaceId) - const filesWithShares = files.map((file) => ({ - ...file, - share: shares.get(file.id) ?? null, - })) + const filesWithShares = await listWorkspaceFilesWithShares(workspaceId, scope) - logger.info(`[${requestId}] Listed ${files.length} files for workspace ${workspaceId}`) + logger.info( + `[${requestId}] Listed ${filesWithShares.length} files for workspace ${workspaceId}` + ) return NextResponse.json({ success: true, diff --git a/apps/sim/app/workspace/[workspaceId]/files/page.tsx b/apps/sim/app/workspace/[workspaceId]/files/page.tsx index 2ed876e4ba0..ba8d25ce7a4 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/page.tsx @@ -1,6 +1,7 @@ import { Suspense } from 'react' import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' +import { getSession } from '@/lib/auth' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { prefetchFilesBrowser } from '@/app/workspace/[workspaceId]/files/prefetch' import { Files } from './files' @@ -19,10 +20,12 @@ export const metadata: Metadata = { * `loading.tsx` covers the navigation/chunk-load transition the same way. */ export default async function FilesPage({ params }: { params: Promise<{ workspaceId: string }> }) { - const { workspaceId } = await params + const [{ workspaceId }, session] = await Promise.all([params, getSession()]) const queryClient = getQueryClient() - await prefetchFilesBrowser(queryClient, workspaceId) + if (session?.user?.id) { + await prefetchFilesBrowser(queryClient, workspaceId, session.user.id) + } return ( diff --git a/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts index 5d94a6f0d7f..250a6e5f713 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts @@ -1,7 +1,7 @@ import type { QueryClient } from '@tanstack/react-query' -import type { WorkspaceFileFolderApi } from '@/lib/api/contracts/workspace-file-folders' -import type { ListWorkspaceFilesResponse } from '@/lib/api/contracts/workspace-files' -import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' +import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' +import { listWorkspaceFilesWithShares } from '@/lib/workspace-files/queries' +import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' import { prefetchResourceListChrome } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome' import { WORKSPACE_FILE_FOLDERS_STALE_TIME, @@ -20,32 +20,32 @@ import { * `useWorkspaceFileFolders`) use (scope `active`), so the browser paints * populated on first render. * - * Both payloads carry `Date` fields, so they go through their routes and cache - * the serialized wire shape — see {@link prefetchInternalJson}. + * Files and folders read the data layer; both payloads are shaped to their route contract so + * a hydrated entry matches a client fetch. Everything else still goes through its route — + * see {@link prefetchInternalJson}. + * + * Those two reads carry no authorization of their own, so the viewer is proved first. This + * reuses the layout's `cache`d host-context lookup rather than re-deriving the permission, + * so it costs no additional queries; a viewer without access caches nothing and the client + * fetch reaches the route for the real 403. */ export async function prefetchFilesBrowser( queryClient: QueryClient, - workspaceId: string + workspaceId: string, + userId: string ): Promise { + const hostContext = await getWorkspaceHostContextForViewer(workspaceId, userId) + if (!hostContext) return + await Promise.all([ queryClient.prefetchQuery({ queryKey: workspaceFilesKeys.list(workspaceId, 'active'), - queryFn: async () => { - const data = await prefetchInternalJson( - `/api/workspaces/${workspaceId}/files?scope=active` - ) - return data.success ? data.files : [] - }, + queryFn: () => listWorkspaceFilesWithShares(workspaceId, 'active'), staleTime: WORKSPACE_FILES_LIST_STALE_TIME, }), queryClient.prefetchQuery({ queryKey: workspaceFileFolderKeys.list(workspaceId, 'active'), - queryFn: async () => { - const data = await prefetchInternalJson<{ folders?: WorkspaceFileFolderApi[] }>( - `/api/workspaces/${workspaceId}/files/folders?scope=active` - ) - return data.folders ?? [] - }, + queryFn: () => listWorkspaceFileFolders(workspaceId, { scope: 'active' }), staleTime: WORKSPACE_FILE_FOLDERS_STALE_TIME, }), prefetchResourceListChrome(queryClient, workspaceId, 'file'), diff --git a/apps/sim/app/workspace/[workspaceId]/lib/prefetch-internal-fetch.ts b/apps/sim/app/workspace/[workspaceId]/lib/prefetch-internal-fetch.ts index e48f6064c17..4ba194395e6 100644 --- a/apps/sim/app/workspace/[workspaceId]/lib/prefetch-internal-fetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/lib/prefetch-internal-fetch.ts @@ -5,12 +5,12 @@ import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' * Server-side GET against an internal `/api` route, forwarding the incoming * request's cookie so the route authenticates as the current user. * - * List prefetches go through the route (rather than the data layer) when the - * payload carries `Date` fields: `NextResponse.json` serializes them to the - * string wire shape the client caches via `requestJson`, so the - * server-hydrated entry byte-matches the client-fetched one through - * dehydration. Calling the data layer directly would cache raw `Date` objects - * and drift from that wire shape. Mirrors the settings/subscription prefetch. + * The legacy path. Reading the data layer and shaping the result through the + * route's response contract — as `files/prefetch.ts` does — is canonical: it + * drops a server-to-server request and its duplicate auth, and the contract + * parse is what guarantees the hydrated entry matches a client fetch. Prefetches + * still on this helper have not been converted; a converted one must prove the + * viewer itself, since the route's own authorization no longer runs. */ export async function prefetchInternalJson(path: string): Promise { const cookie = (await headers()).get('cookie') diff --git a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts index 392202a2224..7d701d22066 100644 --- a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts @@ -4,10 +4,28 @@ import { QueryClient } from '@tanstack/react-query' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockPrefetchInternalJson } = vi.hoisted(() => ({ +const { + mockGetWorkspaceHostContextForViewer, + mockListWorkspaceFileFolders, + mockListWorkspaceFilesWithShares, + mockPrefetchInternalJson, +} = vi.hoisted(() => ({ + mockGetWorkspaceHostContextForViewer: vi.fn(), + mockListWorkspaceFileFolders: vi.fn(), + mockListWorkspaceFilesWithShares: vi.fn(), mockPrefetchInternalJson: vi.fn(), })) +vi.mock('@/lib/workspaces/host-context', () => ({ + getWorkspaceHostContextForViewer: mockGetWorkspaceHostContextForViewer, +})) +vi.mock('@/lib/workspace-files/queries', () => ({ + listWorkspaceFilesWithShares: mockListWorkspaceFilesWithShares, +})) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ + listWorkspaceFileFolders: mockListWorkspaceFileFolders, +})) + vi.mock('@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch', () => ({ prefetchInternalJson: mockPrefetchInternalJson, })) @@ -29,6 +47,7 @@ import { workspaceFileFolderKeys } from '@/hooks/queries/workspace-file-folders' import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' const WORKSPACE_ID = 'ws-123' +const USER_ID = 'user-1' function makeClient() { return new QueryClient({ defaultOptions: { queries: { retry: false } } }) @@ -37,6 +56,9 @@ function makeClient() { describe('workspace list prefetches', () => { beforeEach(() => { vi.clearAllMocks() + mockGetWorkspaceHostContextForViewer.mockResolvedValue({ viewer: { permission: 'admin' } }) + mockListWorkspaceFilesWithShares.mockResolvedValue([]) + mockListWorkspaceFileFolders.mockResolvedValue([]) }) describe('prefetchKnowledgeBases', () => { @@ -73,34 +95,32 @@ describe('workspace list prefetches', () => { it('primes both file + folder keys the client hooks read', async () => { const files = [{ id: 'f-1' }] const folders = [{ id: 'folder-1' }] - mockPrefetchInternalJson.mockImplementation(async (path: string) => - path.includes('/folders') ? { folders } : { success: true, files } - ) + mockListWorkspaceFilesWithShares.mockResolvedValue(files) + mockListWorkspaceFileFolders.mockResolvedValue(folders) const client = makeClient() - await prefetchFilesBrowser(client, WORKSPACE_ID) + await prefetchFilesBrowser(client, WORKSPACE_ID, USER_ID) - expect(mockPrefetchInternalJson).toHaveBeenCalledWith( - `/api/workspaces/${WORKSPACE_ID}/files?scope=active` - ) - expect(mockPrefetchInternalJson).toHaveBeenCalledWith( - `/api/workspaces/${WORKSPACE_ID}/files/folders?scope=active` - ) + expect(mockListWorkspaceFilesWithShares).toHaveBeenCalledWith(WORKSPACE_ID, 'active') + expect(mockListWorkspaceFileFolders).toHaveBeenCalledWith(WORKSPACE_ID, { scope: 'active' }) expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual(files) expect(client.getQueryData(workspaceFileFolderKeys.list(WORKSPACE_ID, 'active'))).toEqual( folders ) }) - it('caches an empty file list when the route reports failure', async () => { - mockPrefetchInternalJson.mockImplementation(async (path: string) => - path.includes('/folders') ? { folders: [] } : { success: false, files: [] } - ) + /** + * The reads bypass the route that used to authorize them, so a viewer without workspace + * access must prime nothing and let the client fetch reach the route for the real 403. + */ + it('caches nothing when the viewer has no workspace access', async () => { + mockGetWorkspaceHostContextForViewer.mockResolvedValue(null) const client = makeClient() - await prefetchFilesBrowser(client, WORKSPACE_ID) + await prefetchFilesBrowser(client, WORKSPACE_ID, USER_ID) - expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual([]) + expect(client.getQueryCache().getAll()).toHaveLength(0) + expect(mockListWorkspaceFilesWithShares).not.toHaveBeenCalled() }) }) @@ -111,9 +131,21 @@ describe('workspace list prefetches', () => { * column. Both must be primed on every foldered page, under the exact client keys. */ const chromeCases = [ - { name: 'files', run: prefetchFilesBrowser, resourceType: 'file' as const }, - { name: 'tables', run: prefetchTables, resourceType: 'table' as const }, - { name: 'knowledge', run: prefetchKnowledgeBases, resourceType: 'knowledge_base' as const }, + { + name: 'files', + run: (client: QueryClient) => prefetchFilesBrowser(client, WORKSPACE_ID, USER_ID), + resourceType: 'file' as const, + }, + { + name: 'tables', + run: (client: QueryClient) => prefetchTables(client, WORKSPACE_ID), + resourceType: 'table' as const, + }, + { + name: 'knowledge', + run: (client: QueryClient) => prefetchKnowledgeBases(client, WORKSPACE_ID), + resourceType: 'knowledge_base' as const, + }, ] for (const { name, run, resourceType } of chromeCases) { @@ -128,7 +160,7 @@ describe('workspace list prefetches', () => { }) const client = makeClient() - await run(client, WORKSPACE_ID) + await run(client) expect(mockPrefetchInternalJson).toHaveBeenCalledWith( `/api/pinned-items?workspaceId=${WORKSPACE_ID}&resourceType=${resourceType}` @@ -193,23 +225,32 @@ describe('workspace list prefetches', () => { it.each([ [ 'prefetchKnowledgeBases', - prefetchKnowledgeBases, + (client: QueryClient) => prefetchKnowledgeBases(client, WORKSPACE_ID), knowledgeKeys.list(WORKSPACE_ID, 'active'), ], - ['prefetchTables', prefetchTables, tableKeys.list(WORKSPACE_ID, 'active')], - ['prefetchHomeLists', prefetchHomeLists, folderKeys.list(WORKSPACE_ID, 'active')], + [ + 'prefetchTables', + (client: QueryClient) => prefetchTables(client, WORKSPACE_ID), + tableKeys.list(WORKSPACE_ID, 'active'), + ], + [ + 'prefetchHomeLists', + (client: QueryClient) => prefetchHomeLists(client, WORKSPACE_ID), + folderKeys.list(WORKSPACE_ID, 'active'), + ], [ 'prefetchFilesBrowser', - prefetchFilesBrowser, + (client: QueryClient) => prefetchFilesBrowser(client, WORKSPACE_ID, USER_ID), workspaceFilesKeys.list(WORKSPACE_ID, 'active'), ], ] as const)( '%s does not throw when the fetcher rejects (page still renders, client refetches)', async (_name, prefetch, queryKey) => { mockPrefetchInternalJson.mockRejectedValue(new Error('500')) + mockListWorkspaceFilesWithShares.mockRejectedValue(new Error('500')) const client = makeClient() - await expect(prefetch(client, WORKSPACE_ID)).resolves.toBeUndefined() + await expect(prefetch(client)).resolves.toBeUndefined() expect(client.getQueryData(queryKey)).toBeUndefined() } ) diff --git a/apps/sim/lib/workspace-files/queries.test.ts b/apps/sim/lib/workspace-files/queries.test.ts new file mode 100644 index 00000000000..e3212079206 --- /dev/null +++ b/apps/sim/lib/workspace-files/queries.test.ts @@ -0,0 +1,75 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetWorkspaceShares, mockListWorkspaceFiles } = vi.hoisted(() => ({ + mockGetWorkspaceShares: vi.fn(), + mockListWorkspaceFiles: vi.fn(), +})) + +vi.mock('@/lib/public-shares/share-manager', () => ({ + getWorkspaceShares: mockGetWorkspaceShares, +})) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + listWorkspaceFiles: mockListWorkspaceFiles, +})) + +import { listWorkspaceFilesWithShares } from '@/lib/workspace-files/queries' + +const STORED_FILE = { + id: 'file-1', + workspaceId: 'ws-1', + name: 'notes.md', + key: 'ws-1/notes.md', + path: '/notes.md', + size: 12, + type: 'text/markdown', + uploadedBy: 'user-1', + folderId: null, + uploadedAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-02T00:00:00.000Z'), + /** Stored, but absent from `workspaceFileRecordSchema`. */ + contentUpdatedAt: new Date('2026-01-03T00:00:00.000Z'), +} + +describe('listWorkspaceFilesWithShares', () => { + beforeEach(() => { + vi.clearAllMocks() + mockListWorkspaceFiles.mockResolvedValue([STORED_FILE]) + mockGetWorkspaceShares.mockResolvedValue(new Map()) + }) + + /** + * The route and the server prefetch both cache this under one query key, and the client + * parses the response through the same contract. A field the contract does not declare + * would sit in a hydrated entry and vanish on the first refetch. + */ + it('strips fields the response contract does not declare', async () => { + const [file] = await listWorkspaceFilesWithShares('ws-1', 'active') + + expect(file).not.toHaveProperty('contentUpdatedAt') + expect(file.id).toBe('file-1') + expect(file.uploadedAt).toEqual(new Date('2026-01-01T00:00:00.000Z')) + }) + + it('joins each file public share onto its row', async () => { + const share = { + id: 'share-1', + token: 'tok', + url: 'https://sim.ai/f/tok', + isActive: true, + resourceType: 'file' as const, + resourceId: 'file-1', + authType: 'public' as const, + hasPassword: false, + allowedEmails: [], + } + mockGetWorkspaceShares.mockResolvedValue(new Map([['file-1', share]])) + + const [file] = await listWorkspaceFilesWithShares('ws-1', 'active') + + expect(file.share).toEqual(share) + expect(mockGetWorkspaceShares).toHaveBeenCalledWith('file', 'ws-1') + }) +}) diff --git a/apps/sim/lib/workspace-files/queries.ts b/apps/sim/lib/workspace-files/queries.ts new file mode 100644 index 00000000000..9a5f0d6b8ca --- /dev/null +++ b/apps/sim/lib/workspace-files/queries.ts @@ -0,0 +1,26 @@ +import { listWorkspaceFilesContract } from '@/lib/api/contracts/workspace-files' +import { getWorkspaceShares } from '@/lib/public-shares/share-manager' +import { + listWorkspaceFiles, + type WorkspaceFileScope, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' + +/** + * Lists a workspace's files with each file's public share joined on — shared by + * `GET /api/workspaces/[id]/files` and the Files browser's server prefetch so both cache + * one shape. + * + * Parsing through the route contract's response schema strips the server-only fields + * `requestJson` strips on the client (`contentUpdatedAt`), so a prefetched entry is identical + * to a client fetch rather than carrying a field that vanishes on the next refetch. + * + * Callers authorize the viewer against `workspaceId` first. + */ +export async function listWorkspaceFilesWithShares(workspaceId: string, scope: WorkspaceFileScope) { + const [files, shares] = await Promise.all([ + listWorkspaceFiles(workspaceId, { scope }), + getWorkspaceShares('file', workspaceId), + ]) + const withShares = files.map((file) => ({ ...file, share: shares.get(file.id) ?? null })) + return listWorkspaceFilesContract.response.schema.shape.files.parse(withShares) +}