fix(files): read the Files prefetch from the data layer and bound invitation previews (#6415)

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.
This commit is contained in:
Waleed
2026-08-08 11:10:49 -07:00
committed by GitHub
parent 3b0651ed9c
commit 8a224be397
9 changed files with 332 additions and 82 deletions
+108
View File
@@ -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)
})
})
+21 -16
View File
@@ -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<Awaited<ReturnType<typeof getInvitationJoinPreview>> | 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(
@@ -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 filesWithShares = await listWorkspaceFilesWithShares(workspaceId, scope)
const shares = await getWorkspaceShares('file', workspaceId)
const filesWithShares = files.map((file) => ({
...file,
share: shares.get(file.id) ?? null,
}))
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,
@@ -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 (
<HydrationBoundary state={dehydrate(queryClient)}>
@@ -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<void> {
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<ListWorkspaceFilesResponse>(
`/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'),
@@ -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<T>(path: string): Promise<T> {
const cookie = (await headers()).get('cookie')
@@ -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()
}
)
@@ -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')
})
})
+26
View File
@@ -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)
}