From c8cc8ad2ce62ae7d12b28304fa48fb066fa69923 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 7 Aug 2026 21:33:47 -0700 Subject: [PATCH 1/4] fix(prefetch): drop the internal HTTP hop from every resource list prefetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 never reaching the client: each prefetch reached its own API route over an internal HTTP request, prefetchQuery swallows a rejection, and shouldDehydrateQuery drops an errored entry. One failed request silently shipped a page with that list missing while its cheaper siblings hydrated fine — and nothing logged it. The files list is the heaviest of the pair, so it lost that race first. Every resource prefetch — files, home, tables, knowledge, and the shared pinned/members chrome — now calls the data layer directly, matching prefetchWorkspaceSidebar. Per page load that turns 5 internal HTTP round-trips, each re-running session and membership authz, into 1 authz read plus N direct reads. Extracts listWorkspaceFilesWithShares, listPinnedItemsForViewer and listKnowledgeBasesForViewer so each route and its prefetch fill the same query key from one function and cannot drift. listKnowledgeBasesForViewer serializes dates because the contract types them z.string(): reading the data layer directly would otherwise cache Date objects that violate the declared type and flip to strings on the first refetch. The routes each authorized their own read, so each prefetch now verifies membership once and caches nothing without access, leaving the client fetch to get the real 403. Also prefetches the viewer's pending invitations with the sidebar so the switcher's "View invitations" entry is present the frame the menu opens, and moves the invitation key factory into hooks/queries/utils so a server prefetch can hydrate it without importing the emcn toast surface. --- apps/sim/app/api/invitations/route.ts | 63 +---- apps/sim/app/api/knowledge/route.ts | 4 +- apps/sim/app/api/pinned-items/route.ts | 48 +--- .../app/api/workspaces/[id]/files/route.ts | 20 +- .../workspace/[workspaceId]/files/page.tsx | 7 +- .../workspace/[workspaceId]/files/prefetch.ts | 41 ++- .../app/workspace/[workspaceId]/home/page.tsx | 9 +- .../workspace/[workspaceId]/home/prefetch.ts | 32 +-- .../[workspaceId]/knowledge/page.tsx | 7 +- .../[workspaceId]/knowledge/prefetch.ts | 33 ++- .../workspace/[workspaceId]/layout.test.tsx | 3 +- .../app/workspace/[workspaceId]/layout.tsx | 3 +- .../lib/prefetch-internal-fetch.ts | 25 -- .../lib/prefetch-resource-list-chrome.ts | 28 +- .../[workspaceId]/lib/prefetch.test.ts | 246 ++++++++++-------- .../app/workspace/[workspaceId]/prefetch.ts | 24 +- .../workspace/[workspaceId]/tables/page.tsx | 7 +- .../[workspaceId]/tables/prefetch.ts | 36 ++- .../view-invitations-menu-item.tsx | 7 +- .../view-invitations-modal.tsx | 14 +- .../workspace-header/workspace-header.tsx | 4 +- apps/sim/hooks/queries/invitations.ts | 55 ++-- .../hooks/queries/utils/invitation-keys.ts | 29 +++ apps/sim/lib/api/contracts/invitations.ts | 8 +- apps/sim/lib/invitations/pending.ts | 70 +++++ apps/sim/lib/knowledge/queries.ts | 28 ++ apps/sim/lib/pinned-items/queries.ts | 62 +++++ apps/sim/lib/workspace-files/queries.ts | 23 ++ 28 files changed, 525 insertions(+), 411 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/lib/prefetch-internal-fetch.ts create mode 100644 apps/sim/hooks/queries/utils/invitation-keys.ts create mode 100644 apps/sim/lib/invitations/pending.ts create mode 100644 apps/sim/lib/knowledge/queries.ts create mode 100644 apps/sim/lib/pinned-items/queries.ts create mode 100644 apps/sim/lib/workspace-files/queries.ts diff --git a/apps/sim/app/api/invitations/route.ts b/apps/sim/app/api/invitations/route.ts index 106a7177799..e1bff103d2e 100644 --- a/apps/sim/app/api/invitations/route.ts +++ b/apps/sim/app/api/invitations/route.ts @@ -1,16 +1,14 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' -import type { MyInvitation } from '@/lib/api/contracts/invitations' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getInvitationJoinPreview, listPendingInvitationsForEmail } from '@/lib/invitations/core' +import { listPendingInvitationsForViewer } from '@/lib/invitations/pending' -const logger = createLogger('MyInvitationsAPI') +const logger = createLogger('InvitationsAPI') /** * Pending invitations addressed to the session's email — the invitee-facing - * list behind the workspace switcher's Invitations section. Acceptance is - * session-bound (email match), so rows deliberately exclude the token. + * list behind the workspace switcher's Invitations section. */ export const GET = withRouteHandler(async () => { const session = await getSession() @@ -20,59 +18,8 @@ export const GET = withRouteHandler(async () => { } try { - const invitations = await listPendingInvitationsForEmail(session.user.email) - - /** - * Each row carries what accepting it will actually do, so the in-app list - * can disclose the workspace migration and echo `disclosedWorkspaceIds` on - * accept — the same consent contract the emailed `/invite` page honours. - * 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. - */ - 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) - } - } - - return NextResponse.json({ - invitations: invitations.map( - (inv, index) => - ({ - id: inv.id, - kind: inv.kind, - email: inv.email, - organizationId: inv.organizationId, - organizationName: inv.organizationName, - membershipIntent: inv.membershipIntent, - role: inv.role, - status: inv.status, - expiresAt: inv.expiresAt.toISOString(), - createdAt: inv.createdAt.toISOString(), - inviterName: inv.inviterName, - inviterEmail: inv.inviterEmail, - grants: inv.grants.map((grant) => ({ - workspaceId: grant.workspaceId, - workspaceName: grant.workspaceName, - permission: grant.permission, - })), - joinPreview: previews[index], - }) satisfies MyInvitation - ), - }) + const invitations = await listPendingInvitationsForViewer(session.user.id, session.user.email) + return NextResponse.json({ invitations }) } catch (error) { logger.error('Failed to list pending invitations', { error }) return NextResponse.json({ error: 'Failed to list invitations' }, { status: 500 }) diff --git a/apps/sim/app/api/knowledge/route.ts b/apps/sim/app/api/knowledge/route.ts index b2f9177b49d..8822b730905 100644 --- a/apps/sim/app/api/knowledge/route.ts +++ b/apps/sim/app/api/knowledge/route.ts @@ -11,9 +11,9 @@ import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { EMBEDDING_DIMENSIONS, getConfiguredEmbeddingModel } from '@/lib/knowledge/embeddings' +import { listKnowledgeBasesForViewer } from '@/lib/knowledge/queries' import { createKnowledgeBase, - getKnowledgeBases, KnowledgeBaseConflictError, KnowledgeBaseFolderError, KnowledgeBasePermissionError, @@ -46,7 +46,7 @@ export const GET = withRouteHandler(async (req: NextRequest) => { } const { workspaceId, scope } = query.data - const knowledgeBasesWithCounts = await getKnowledgeBases( + const knowledgeBasesWithCounts = await listKnowledgeBasesForViewer( session.user.id, workspaceId, scope as KnowledgeBaseScope diff --git a/apps/sim/app/api/pinned-items/route.ts b/apps/sim/app/api/pinned-items/route.ts index bf31285fb61..0f88773d864 100644 --- a/apps/sim/app/api/pinned-items/route.ts +++ b/apps/sim/app/api/pinned-items/route.ts @@ -2,40 +2,21 @@ import { db, pinnedItem } from '@sim/db' import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, eq, ne } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createPinnedItemContract, listPinnedItemsContract, type PinnedItemApi, - pinnedResourceTypeSchema, } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { filterToActiveResources, pinnableResourceExists } from '@/lib/pinned-items/resources' +import { listPinnedItemsForViewer } from '@/lib/pinned-items/queries' +import { pinnableResourceExists } from '@/lib/pinned-items/resources' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('PinnedItemsAPI') -/** - * Narrows a stored row to the wire shape, dropping any row whose `resourceType` this build does - * not recognise. - * - * `pinned_item.resource_type` is plain `text` — deliberately, so the set of pinnable kinds can - * grow — while the contract is a closed enum. During a rolling deploy an older pod can therefore - * read a pin a newer one wrote. Returning it would fail response validation and take the WHOLE - * list down rather than the single row, so the unknown kind is skipped instead. - * - * `filterToActiveResources` already drops these as a side effect of not having a table to look - * them up in; this makes the guarantee explicit and compiler-checked at the wire boundary. - */ -function toPinnedItemApi(row: typeof pinnedItem.$inferSelect): PinnedItemApi | null { - const resourceType = pinnedResourceTypeSchema.safeParse(row.resourceType) - if (!resourceType.success) return null - return { ...row, resourceType: resourceType.data, pinnedAt: row.pinnedAt.toISOString() } -} - /** Lists the session user's pinned items in a workspace, optionally filtered to one `resourceType`. */ export const GET = withRouteHandler(async (request: NextRequest) => { const session = await getSession() @@ -52,30 +33,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Access denied to this workspace' }, { status: 403 }) } - const rows = await db - .select() - .from(pinnedItem) - .where( - and( - eq(pinnedItem.userId, session.user.id), - eq(pinnedItem.workspaceId, workspaceId), - /** - * A `workspace` pin stores `workspaceId === resourceId`, so it would otherwise - * appear in this workspace's unscoped listing as a resource *inside* itself. - * It is read from the workspace-list payload instead, so it is excluded here - * rather than left for a future unscoped caller to mistake for a real resource. - */ - resourceType - ? eq(pinnedItem.resourceType, resourceType) - : ne(pinnedItem.resourceType, 'workspace') - ) - ) - - const activeRows = await filterToActiveResources(rows, workspaceId) - - const pinnedItems = activeRows - .map(toPinnedItemApi) - .filter((item): item is PinnedItemApi => item !== null) + const pinnedItems = await listPinnedItemsForViewer(session.user.id, workspaceId, resourceType) return NextResponse.json({ pinnedItems }) }) 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..403d85758fc 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' +import { listWorkspaceFilesWithShares } from '@/lib/workspace-files/queries' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { prefetchResourceListChrome } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome' import { WORKSPACE_FILE_FOLDERS_STALE_TIME, @@ -17,37 +17,36 @@ import { * first frame: workspace files, file folders, and (via {@link prefetchResourceListChrome}) * the pinned ids that drive row order plus the members behind the Owner column — * under the same query keys their client hooks (`useWorkspaceFiles`, - * `useWorkspaceFileFolders`) use (scope `active`), so the browser paints - * populated on first render. + * `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}. + * Calls the data layer directly — the same functions the API routes use — matching + * `prefetchWorkspaceSidebar`. A rejection here is swallowed by `prefetchQuery` and the + * errored entry dropped by `shouldDehydrateQuery`, so one list failing must not take + * its siblings down with it. + * + * Membership is verified once rather than per-list. Without access nothing is cached, + * so the client fetch reaches the route and gets the real 403. */ export async function prefetchFilesBrowser( queryClient: QueryClient, - workspaceId: string + workspaceId: string, + userId: string ): Promise { + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (!permission) 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'), + prefetchResourceListChrome(queryClient, workspaceId, userId, 'file'), ]) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/page.tsx b/apps/sim/app/workspace/[workspaceId]/home/page.tsx index b7a6cc4ea95..c5e94005f89 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/page.tsx @@ -24,12 +24,13 @@ export default async function HomePage({ params }: { params: Promise<{ workspace } const queryClient = getQueryClient() - const listsPrefetch = prefetchHomeLists(queryClient, workspaceId) - const session = await getSession() const userId = session?.user?.id - const tableViewsEnabled = await resolveTableViewsEnabled(workspaceId, userId) - await listsPrefetch + + const [tableViewsEnabled] = await Promise.all([ + resolveTableViewsEnabled(workspaceId, userId), + userId ? prefetchHomeLists(queryClient, workspaceId, userId) : Promise.resolve(), + ]) return ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts index f08791c0bbd..78ac01dd71f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts @@ -1,7 +1,7 @@ import type { QueryClient } from '@tanstack/react-query' -import type { FolderApi } from '@/lib/api/contracts' -import type { ListWorkspaceFilesResponse } from '@/lib/api/contracts/workspace-files' -import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' +import { listFoldersForWorkspace } from '@/lib/folders/queries' +import { listWorkspaceFilesWithShares } from '@/lib/workspace-files/queries' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { FOLDER_LIST_STALE_TIME, folderKeys, mapFolder } from '@/hooks/queries/utils/folder-keys' import { WORKSPACE_FILES_LIST_STALE_TIME, @@ -16,34 +16,30 @@ import { * The workflow list (`workflowKeys.list(ws, 'active')`) is already hydrated by * the workspace sidebar prefetch and is intentionally not repeated here. * - * Folders are fetched through the route and mapped with the same `mapFolder` - * the hook applies, matching its cached shape (string dates → `Date`). Files - * carry `Date` fields, so they go through the route and cache the serialized - * wire shape — see {@link prefetchInternalJson}. + * Folders are mapped with the same `mapFolder` the hook applies, and files go through the + * same `listWorkspaceFilesWithShares` the Files browser and the route use, so the hydrated + * entry matches a client fetch exactly. */ export async function prefetchHomeLists( queryClient: QueryClient, - workspaceId: string + workspaceId: string, + userId: string ): Promise { + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (!permission) return + await Promise.all([ queryClient.prefetchQuery({ queryKey: folderKeys.list(workspaceId, 'active', 'workflow'), queryFn: async () => { - const { folders } = await prefetchInternalJson<{ folders?: FolderApi[] }>( - `/api/folders?workspaceId=${workspaceId}&scope=active&resourceType=workflow` - ) - return (folders ?? []).map(mapFolder) + const folders = await listFoldersForWorkspace(workspaceId, 'active', 'workflow') + return folders.map(mapFolder) }, staleTime: FOLDER_LIST_STALE_TIME, }), 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, }), ]) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx index 402d437b4f0..2a5268e536e 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/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 KnowledgeLoading from '@/app/workspace/[workspaceId]/knowledge/loading' import { prefetchKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/prefetch' @@ -21,10 +22,12 @@ export default async function KnowledgePage({ }: { params: Promise<{ workspaceId: string }> }) { - const { workspaceId } = await params + const [{ workspaceId }, session] = await Promise.all([params, getSession()]) const queryClient = getQueryClient() - await prefetchKnowledgeBases(queryClient, workspaceId) + if (session?.user?.id) { + await prefetchKnowledgeBases(queryClient, workspaceId, session.user.id) + } return ( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts index 7c9d45cb668..0a99698a685 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts @@ -1,7 +1,7 @@ import type { QueryClient } from '@tanstack/react-query' -import type { FolderApi } from '@/lib/api/contracts/folders' -import type { KnowledgeBaseData } from '@/lib/api/contracts/knowledge' -import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' +import { listFoldersForWorkspace } from '@/lib/folders/queries' +import { listKnowledgeBasesForViewer } from '@/lib/knowledge/queries' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { prefetchResourceListChrome } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome' import { FOLDER_LIST_STALE_TIME, folderKeys, mapFolder } from '@/hooks/queries/utils/folder-keys' import { KNOWLEDGE_BASE_LIST_STALE_TIME, knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' @@ -16,35 +16,32 @@ import { KNOWLEDGE_BASE_LIST_STALE_TIME, knowledgeKeys } from '@/hooks/queries/u * beside, so prefetching one without the other still flashes an ungrouped list — and a * `?folderId=` deep link renders an empty breadcrumb until the folders arrive. * - * The list carries `Date` fields, so it goes through the `/api/knowledge` route and caches the - * serialized wire shape — see {@link prefetchInternalJson}. Folders are mapped with the same - * `mapFolder` the hook applies, so the hydrated entry matches a client fetch exactly. + * `listKnowledgeBasesForViewer` is viewer-scoped and returns the contract's wire shape, + * and folders go through the same `mapFolder` the hook applies — so both hydrated + * entries match a client fetch exactly. */ export async function prefetchKnowledgeBases( queryClient: QueryClient, - workspaceId: string + workspaceId: string, + userId: string ): Promise { + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (!permission) return + await Promise.all([ queryClient.prefetchQuery({ queryKey: knowledgeKeys.list(workspaceId, 'active'), - queryFn: async () => { - const result = await prefetchInternalJson<{ data: KnowledgeBaseData[] }>( - `/api/knowledge?workspaceId=${workspaceId}&scope=active` - ) - return result.data - }, + queryFn: () => listKnowledgeBasesForViewer(userId, workspaceId, 'active'), staleTime: KNOWLEDGE_BASE_LIST_STALE_TIME, }), queryClient.prefetchQuery({ queryKey: folderKeys.list(workspaceId, 'active', 'knowledge_base'), queryFn: async () => { - const { folders } = await prefetchInternalJson<{ folders?: FolderApi[] }>( - `/api/folders?workspaceId=${workspaceId}&scope=active&resourceType=knowledge_base` - ) - return (folders ?? []).map(mapFolder) + const folders = await listFoldersForWorkspace(workspaceId, 'active', 'knowledge_base') + return folders.map(mapFolder) }, staleTime: FOLDER_LIST_STALE_TIME, }), - prefetchResourceListChrome(queryClient, workspaceId, 'knowledge_base'), + prefetchResourceListChrome(queryClient, workspaceId, userId, 'knowledge_base'), ]) } diff --git a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx index 26305f13a74..6f4bf7f3fd9 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx @@ -163,7 +163,8 @@ describe('WorkspaceLayout host context', () => { 'workspace-b', 'viewer-1', HOST_CONTEXT, - 'org-a' + 'org-a', + null ) expect(mockBrandingProvider).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index eb58536782e..a1393cbc702 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -55,7 +55,8 @@ export default async function WorkspaceLayout({ workspaceId, session.user.id, hostContext, - activeOrganizationId + activeOrganizationId, + session.user.email ?? null ), ]) const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1' diff --git a/apps/sim/app/workspace/[workspaceId]/lib/prefetch-internal-fetch.ts b/apps/sim/app/workspace/[workspaceId]/lib/prefetch-internal-fetch.ts deleted file mode 100644 index e48f6064c17..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/lib/prefetch-internal-fetch.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { headers } from 'next/headers' -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. - */ -export async function prefetchInternalJson(path: string): Promise { - const cookie = (await headers()).get('cookie') - // boundary-raw-fetch: server-side RSC prefetch forwarding the session cookie to an internal API route; requestJson is client-only and cannot run here - const response = await fetch(`${getInternalApiBaseUrl()}${path}`, { - headers: cookie ? { cookie } : {}, - }) - if (!response.ok) { - throw new Error(`Prefetch failed for ${path}: ${response.status}`) - } - return response.json() as Promise -} diff --git a/apps/sim/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome.ts b/apps/sim/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome.ts index 5d9241aa23f..5499595989c 100644 --- a/apps/sim/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome.ts +++ b/apps/sim/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome.ts @@ -1,12 +1,9 @@ import type { QueryClient } from '@tanstack/react-query' -import type { PinnedItemApi, PinnedResourceType } from '@/lib/api/contracts/pinned-items' -import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' +import type { PinnedResourceType } from '@/lib/api/contracts/pinned-items' +import { listPinnedItemsForViewer } from '@/lib/pinned-items/queries' +import { getWorkspaceMemberProfiles } from '@/lib/workspaces/permissions/utils' import { PINNED_ITEMS_STALE_TIME, pinnedItemKeys } from '@/hooks/queries/utils/pinned-item-keys' -import { - WORKSPACE_MEMBERS_STALE_TIME, - type WorkspaceMember, - workspaceKeys, -} from '@/hooks/queries/workspace' +import { WORKSPACE_MEMBERS_STALE_TIME, workspaceKeys } from '@/hooks/queries/workspace' /** * Prefetches the two lists every foldered resource page needs to paint a row completely, @@ -19,21 +16,19 @@ import { * * Members back the Owner column; without them every owner cell paints empty and fills in * after. Both are cheap and shared with the page's own list prefetch in one `Promise.all`. + * + * The caller has already authorized the viewer against `workspaceId`. */ export async function prefetchResourceListChrome( queryClient: QueryClient, workspaceId: string, + userId: string, resourceType: PinnedResourceType ): Promise { const prefetchPinned = (type: PinnedResourceType) => queryClient.prefetchQuery({ queryKey: pinnedItemKeys.list(workspaceId, type), - queryFn: async () => { - const { pinnedItems } = await prefetchInternalJson<{ pinnedItems: PinnedItemApi[] }>( - `/api/pinned-items?workspaceId=${workspaceId}&resourceType=${type}` - ) - return pinnedItems - }, + queryFn: () => listPinnedItemsForViewer(userId, workspaceId, type), staleTime: PINNED_ITEMS_STALE_TIME, }) @@ -42,12 +37,7 @@ export async function prefetchResourceListChrome( prefetchPinned('folder'), queryClient.prefetchQuery({ queryKey: workspaceKeys.members(workspaceId), - queryFn: async () => { - const { members } = await prefetchInternalJson<{ members: WorkspaceMember[] }>( - `/api/workspaces/${workspaceId}/members` - ) - return members - }, + queryFn: () => getWorkspaceMemberProfiles(workspaceId), staleTime: WORKSPACE_MEMBERS_STALE_TIME, }), ]) diff --git a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts index 392202a2224..5d495833777 100644 --- a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts @@ -4,12 +4,43 @@ import { QueryClient } from '@tanstack/react-query' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockPrefetchInternalJson } = vi.hoisted(() => ({ - mockPrefetchInternalJson: vi.fn(), +const { + mockListKnowledgeBasesForViewer, + mockGetUserEntityPermissions, + mockGetWorkspaceMemberProfiles, + mockListFoldersForWorkspace, + mockListPinnedItemsForViewer, + mockListTables, + mockListWorkspaceFileFolders, + mockListWorkspaceFilesWithShares, +} = vi.hoisted(() => ({ + mockListKnowledgeBasesForViewer: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockGetWorkspaceMemberProfiles: vi.fn(), + mockListFoldersForWorkspace: vi.fn(), + mockListPinnedItemsForViewer: vi.fn(), + mockListTables: vi.fn(), + mockListWorkspaceFileFolders: vi.fn(), + mockListWorkspaceFilesWithShares: vi.fn(), })) -vi.mock('@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch', () => ({ - prefetchInternalJson: mockPrefetchInternalJson, +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, + getWorkspaceMemberProfiles: mockGetWorkspaceMemberProfiles, +})) +vi.mock('@/lib/pinned-items/queries', () => ({ + listPinnedItemsForViewer: mockListPinnedItemsForViewer, +})) +vi.mock('@/lib/folders/queries', () => ({ listFoldersForWorkspace: mockListFoldersForWorkspace })) +vi.mock('@/lib/workspace-files/queries', () => ({ + listWorkspaceFilesWithShares: mockListWorkspaceFilesWithShares, +})) +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + listWorkspaceFileFolders: mockListWorkspaceFileFolders, +})) +vi.mock('@/lib/table', () => ({ listTables: mockListTables })) +vi.mock('@/lib/knowledge/queries', () => ({ + listKnowledgeBasesForViewer: mockListKnowledgeBasesForViewer, })) vi.mock('@sim/emcn', () => ({ @@ -29,6 +60,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,34 +69,38 @@ function makeClient() { describe('workspace list prefetches', () => { beforeEach(() => { vi.clearAllMocks() + mockGetUserEntityPermissions.mockResolvedValue('admin') + mockListPinnedItemsForViewer.mockResolvedValue([]) + mockGetWorkspaceMemberProfiles.mockResolvedValue([]) + mockListFoldersForWorkspace.mockResolvedValue([]) + mockListWorkspaceFilesWithShares.mockResolvedValue([]) + mockListWorkspaceFileFolders.mockResolvedValue([]) + mockListTables.mockResolvedValue([]) + mockListKnowledgeBasesForViewer.mockResolvedValue([]) }) describe('prefetchKnowledgeBases', () => { - it('primes the exact key useKnowledgeBasesQuery reads and unwraps data', async () => { + it('primes the exact key useKnowledgeBasesQuery reads, scoped to the viewer', async () => { const bases = [{ id: 'kb-1' }] - mockPrefetchInternalJson.mockResolvedValue({ data: bases }) + mockListKnowledgeBasesForViewer.mockResolvedValue(bases) const client = makeClient() - await prefetchKnowledgeBases(client, WORKSPACE_ID) + await prefetchKnowledgeBases(client, WORKSPACE_ID, USER_ID) - expect(mockPrefetchInternalJson).toHaveBeenCalledWith( - `/api/knowledge?workspaceId=${WORKSPACE_ID}&scope=active` - ) + expect(mockListKnowledgeBasesForViewer).toHaveBeenCalledWith(USER_ID, WORKSPACE_ID, 'active') expect(client.getQueryData(knowledgeKeys.list(WORKSPACE_ID, 'active'))).toEqual(bases) }) }) describe('prefetchTables', () => { - it('primes the exact key useTablesList reads and unwraps data.tables', async () => { + it('primes the exact key useTablesList reads', async () => { const tables = [{ id: 't-1' }] - mockPrefetchInternalJson.mockResolvedValue({ data: { tables } }) + mockListTables.mockResolvedValue(tables) const client = makeClient() - await prefetchTables(client, WORKSPACE_ID) + await prefetchTables(client, WORKSPACE_ID, USER_ID) - expect(mockPrefetchInternalJson).toHaveBeenCalledWith( - `/api/table?workspaceId=${WORKSPACE_ID}&scope=active` - ) + expect(mockListTables).toHaveBeenCalledWith(WORKSPACE_ID, { scope: 'active' }) expect(client.getQueryData(tableKeys.list(WORKSPACE_ID, 'active'))).toEqual(tables) }) }) @@ -73,37 +109,81 @@ 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: [] } + /** + * `prefetchQuery` swallows a rejection and `shouldDehydrateQuery` drops the errored + * entry, so one failing read can silently ship a page with that list missing. + */ + it('still primes folders when the files read throws', async () => { + const folders = [{ id: 'folder-1' }] + mockListWorkspaceFilesWithShares.mockRejectedValue(new Error('files read failed')) + mockListWorkspaceFileFolders.mockResolvedValue(folders) + const client = makeClient() + + await prefetchFilesBrowser(client, WORKSPACE_ID, USER_ID) + + expect(client.getQueryData(workspaceFileFolderKeys.list(WORKSPACE_ID, 'active'))).toEqual( + folders ) + expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toBeUndefined() + }) + }) + + describe('prefetchHomeLists', () => { + it('primes the workflow folder tree and the file list', async () => { + const files = [{ id: 'f-1' }] + mockListWorkspaceFilesWithShares.mockResolvedValue(files) const client = makeClient() - await prefetchFilesBrowser(client, WORKSPACE_ID) + await prefetchHomeLists(client, WORKSPACE_ID, USER_ID) - expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual([]) + expect(mockListFoldersForWorkspace).toHaveBeenCalledWith(WORKSPACE_ID, 'active', 'workflow') + expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual(files) }) }) + describe('authorization', () => { + /** + * The prefetches call the data layer directly, bypassing the routes that used to + * authorize each read. A viewer without workspace access must therefore prime nothing — + * the client fetch then reaches the route and gets the real 403. + */ + const allPrefetches = [ + { name: 'files', run: prefetchFilesBrowser }, + { name: 'tables', run: prefetchTables }, + { name: 'knowledge', run: prefetchKnowledgeBases }, + { name: 'home', run: prefetchHomeLists }, + ] + + for (const { name, run } of allPrefetches) { + it(`caches nothing for ${name} when the viewer has no workspace access`, async () => { + mockGetUserEntityPermissions.mockResolvedValue(null) + const client = makeClient() + + await run(client, WORKSPACE_ID, USER_ID) + + expect(client.getQueryCache().getAll()).toHaveLength(0) + expect(mockListWorkspaceFilesWithShares).not.toHaveBeenCalled() + expect(mockListTables).not.toHaveBeenCalled() + expect(mockListKnowledgeBasesForViewer).not.toHaveBeenCalled() + expect(mockListPinnedItemsForViewer).not.toHaveBeenCalled() + }) + } + }) + describe('resource-list chrome', () => { /** * Pinned ids are the list's primary sort key, so a page that paints without them renders @@ -120,25 +200,19 @@ describe('workspace list prefetches', () => { it(`primes pinned ids (${resourceType} + folder) and members for ${name}`, async () => { const pinnedItems = [{ id: 'p-1', resourceId: 'r-1' }] const members = [{ userId: 'u-1', name: 'Ada' }] - mockPrefetchInternalJson.mockImplementation(async (path: string) => { - if (path.startsWith('/api/pinned-items')) return { pinnedItems } - if (path.endsWith('/members')) return { members } - if (path.includes('/folders')) return { folders: [] } - return { success: true, files: [], data: { tables: [] } } - }) + mockListPinnedItemsForViewer.mockResolvedValue(pinnedItems) + mockGetWorkspaceMemberProfiles.mockResolvedValue(members) const client = makeClient() - await run(client, WORKSPACE_ID) + await run(client, WORKSPACE_ID, USER_ID) - expect(mockPrefetchInternalJson).toHaveBeenCalledWith( - `/api/pinned-items?workspaceId=${WORKSPACE_ID}&resourceType=${resourceType}` - ) - expect(mockPrefetchInternalJson).toHaveBeenCalledWith( - `/api/pinned-items?workspaceId=${WORKSPACE_ID}&resourceType=folder` - ) - expect(mockPrefetchInternalJson).toHaveBeenCalledWith( - `/api/workspaces/${WORKSPACE_ID}/members` + expect(mockListPinnedItemsForViewer).toHaveBeenCalledWith( + USER_ID, + WORKSPACE_ID, + resourceType ) + expect(mockListPinnedItemsForViewer).toHaveBeenCalledWith(USER_ID, WORKSPACE_ID, 'folder') + expect(mockGetWorkspaceMemberProfiles).toHaveBeenCalledWith(WORKSPACE_ID) expect(client.getQueryData(pinnedItemKeys.list(WORKSPACE_ID, resourceType))).toEqual( pinnedItems ) @@ -150,68 +224,28 @@ describe('workspace list prefetches', () => { } }) - describe('prefetchHomeLists', () => { - it('primes folder + file keys, mapping folder rows to the client shape', async () => { - const folderRow = { - id: 'folder-1', - name: 'Docs', - userId: 'u-1', - workspaceId: WORKSPACE_ID, - parentId: null, - resourceType: 'workflow', - locked: false, - sortOrder: 0, - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-02T00:00:00.000Z', - deletedAt: null, - } - const files = [{ id: 'f-1' }] - mockPrefetchInternalJson.mockImplementation(async (path: string) => - path.startsWith('/api/folders') ? { folders: [folderRow] } : { success: true, files } - ) - const client = makeClient() - - await prefetchHomeLists(client, WORKSPACE_ID) - - expect(mockPrefetchInternalJson).toHaveBeenCalledWith( - `/api/folders?workspaceId=${WORKSPACE_ID}&scope=active&resourceType=workflow` - ) - const cachedFolders = client.getQueryData(folderKeys.list(WORKSPACE_ID, 'active')) as Array<{ - id: string - resourceType: string - createdAt: Date - }> - expect(cachedFolders).toHaveLength(1) - expect(cachedFolders[0].resourceType).toBe('workflow') - // The wire shape carries ISO strings; the client shape carries Dates. - expect(cachedFolders[0].createdAt).toBeInstanceOf(Date) - expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual(files) - }) - }) + describe('folder trees', () => { + const folderCases = [ + { name: 'tables', run: prefetchTables, resourceType: 'table' as const }, + { name: 'knowledge', run: prefetchKnowledgeBases, resourceType: 'knowledge_base' as const }, + ] - describe('graceful failure', () => { - it.each([ - [ - 'prefetchKnowledgeBases', - prefetchKnowledgeBases, - knowledgeKeys.list(WORKSPACE_ID, 'active'), - ], - ['prefetchTables', prefetchTables, tableKeys.list(WORKSPACE_ID, 'active')], - ['prefetchHomeLists', prefetchHomeLists, folderKeys.list(WORKSPACE_ID, 'active')], - [ - 'prefetchFilesBrowser', - prefetchFilesBrowser, - 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')) + for (const { name, run, resourceType } of folderCases) { + it(`primes the ${name} folder tree under its own resourceType key`, async () => { + mockListFoldersForWorkspace.mockResolvedValue([]) const client = makeClient() - await expect(prefetch(client, WORKSPACE_ID)).resolves.toBeUndefined() - expect(client.getQueryData(queryKey)).toBeUndefined() - } - ) + await run(client, WORKSPACE_ID, USER_ID) + + expect(mockListFoldersForWorkspace).toHaveBeenCalledWith( + WORKSPACE_ID, + 'active', + resourceType + ) + expect(client.getQueryData(folderKeys.list(WORKSPACE_ID, 'active', resourceType))).toEqual( + [] + ) + }) + } }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/prefetch.ts index fe69e488fae..395d8405694 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/prefetch.ts @@ -3,6 +3,7 @@ import { listWorkspacesContract, type WorkspaceHostContext } from '@/lib/api/con import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats' import { isChatEnabled } from '@/lib/core/config/env-flags' import { listFoldersForWorkspace } from '@/lib/folders/queries' +import { listPendingInvitationsForViewer } from '@/lib/invitations/pending' import { getUserProfile } from '@/lib/users/queries' import { listWorkflowsForUser } from '@/lib/workflows/queries' import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' @@ -19,6 +20,10 @@ import { userProfileKeys, } from '@/hooks/queries/user-profile' import { FOLDER_LIST_STALE_TIME, folderKeys, mapFolder } from '@/hooks/queries/utils/folder-keys' +import { + invitationKeys, + VIEWER_INVITATIONS_STALE_TIME, +} from '@/hooks/queries/utils/invitation-keys' import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' import { mapWorkflow, WORKFLOW_LIST_STALE_TIME } from '@/hooks/queries/utils/workflow-list-query' import { @@ -49,7 +54,7 @@ export function prefetchWorkspaceHostContext( /** * Prefetches the sidebar's workflow, chat, folder, workspace-permissions, - * workspace, and viewer-profile reads for a workspace and stores them under the + * workspace, viewer-profile, and pending-invitation reads for a workspace and stores them under the * same query keys + mappers the client hooks use, so the persistent sidebar * (including the workspace switcher header and the footer's profile row) paints * populated on the first server render @@ -69,7 +74,8 @@ export async function prefetchWorkspaceSidebar( workspaceId: string, userId: string, hostContext: WorkspaceHostContext, - activeOrganizationId: string | null + activeOrganizationId: string | null, + userEmail: string | null ): Promise { if (hostContext.workspace.id !== workspaceId) return await Promise.all([ @@ -148,5 +154,19 @@ export async function prefetchWorkspaceSidebar( }, staleTime: USER_PROFILE_STALE_TIME, }), + /** + * The switcher's "View invitations" entry renders only when the viewer has pending + * invitations, and it mounts inside the dropdown — fetching on open made the entry + * appear a beat after the menu did. + */ + ...(userEmail + ? [ + queryClient.prefetchQuery({ + queryKey: invitationKeys.viewer(), + queryFn: () => listPendingInvitationsForViewer(userId, userEmail), + staleTime: VIEWER_INVITATIONS_STALE_TIME, + }), + ] + : []), ]) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/page.tsx b/apps/sim/app/workspace/[workspaceId]/tables/page.tsx index 0e9390a5d95..b5430eb2aca 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/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 TablesLoading from '@/app/workspace/[workspaceId]/tables/loading' import { prefetchTables } from '@/app/workspace/[workspaceId]/tables/prefetch' @@ -17,10 +18,12 @@ export const metadata: Metadata = { * route-level `loading.tsx` covers the navigation/chunk-load transition. */ export default async function TablesPage({ params }: { params: Promise<{ workspaceId: string }> }) { - const { workspaceId } = await params + const [{ workspaceId }, session] = await Promise.all([params, getSession()]) const queryClient = getQueryClient() - await prefetchTables(queryClient, workspaceId) + if (session?.user?.id) { + await prefetchTables(queryClient, workspaceId, session.user.id) + } return ( diff --git a/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts index 5a548885511..bd105db76c5 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts @@ -1,7 +1,7 @@ import type { QueryClient } from '@tanstack/react-query' -import type { FolderApi } from '@/lib/api/contracts/folders' -import type { TableDefinition } from '@/lib/table' -import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' +import { listFoldersForWorkspace } from '@/lib/folders/queries' +import { listTables } from '@/lib/table' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { prefetchResourceListChrome } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome' import { FOLDER_LIST_STALE_TIME, folderKeys, mapFolder } from '@/hooks/queries/utils/folder-keys' import { TABLE_LIST_STALE_TIME, tableKeys } from '@/hooks/queries/utils/table-keys' @@ -14,33 +14,31 @@ import { TABLE_LIST_STALE_TIME, tableKeys } from '@/hooks/queries/utils/table-ke * only placed correctly relative to the folder rows it sits beside, so * prefetching one without the other still flashes an ungrouped list. * - * Table definitions carry `Date` fields, so the list goes through the - * `/api/table` route and caches the serialized wire shape — see - * {@link prefetchInternalJson}. Folders are mapped with the same `mapFolder` the - * hook applies so the hydrated entry matches a client fetch exactly. + * Folders are mapped with the same `mapFolder` the hook applies so the hydrated entry + * matches a client fetch exactly. */ -export async function prefetchTables(queryClient: QueryClient, workspaceId: string): Promise { +export async function prefetchTables( + queryClient: QueryClient, + workspaceId: string, + userId: string +): Promise { + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (!permission) return + await Promise.all([ queryClient.prefetchQuery({ queryKey: tableKeys.list(workspaceId, 'active'), - queryFn: async () => { - const response = await prefetchInternalJson<{ data: { tables: TableDefinition[] } }>( - `/api/table?workspaceId=${workspaceId}&scope=active` - ) - return response.data.tables - }, + queryFn: () => listTables(workspaceId, { scope: 'active' }), staleTime: TABLE_LIST_STALE_TIME, }), queryClient.prefetchQuery({ queryKey: folderKeys.list(workspaceId, 'active', 'table'), queryFn: async () => { - const { folders } = await prefetchInternalJson<{ folders?: FolderApi[] }>( - `/api/folders?workspaceId=${workspaceId}&scope=active&resourceType=table` - ) - return (folders ?? []).map(mapFolder) + const folders = await listFoldersForWorkspace(workspaceId, 'active', 'table') + return folders.map(mapFolder) }, staleTime: FOLDER_LIST_STALE_TIME, }), - prefetchResourceListChrome(queryClient, workspaceId, 'table'), + prefetchResourceListChrome(queryClient, workspaceId, userId, 'table'), ]) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-menu-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-menu-item.tsx index c0d1e12e5c2..395e760329b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-menu-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-menu-item.tsx @@ -2,7 +2,7 @@ import { Chip } from '@sim/emcn' import { Mail } from '@sim/emcn/icons' -import { useMyPendingInvitations } from '@/hooks/queries/invitations' +import { usePendingInvitationsForViewer } from '@/hooks/queries/invitations' interface ViewInvitationsMenuItemProps { /** Close the workspace menu and open the invitations modal. */ @@ -12,10 +12,11 @@ interface ViewInvitationsMenuItemProps { /** * "View invitations" entry in the workspace switcher — rendered only when the * signed-in account has pending invitations. Mounted inside the dropdown - * content, so the check runs when the menu opens (cached between opens). + * content, but the list is hydrated by the sidebar's server prefetch, so the + * entry is there on the frame the menu opens instead of popping in after it. */ export function ViewInvitationsMenuItem({ onOpen }: ViewInvitationsMenuItemProps) { - const { data: invitations } = useMyPendingInvitations() + const { data: invitations } = usePendingInvitationsForViewer() if (!invitations || invitations.length === 0) { return null diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.tsx index 541db1b29e5..16366f2a16c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.tsx @@ -4,12 +4,12 @@ import { Chip, ChipModal, ChipModalBody, ChipModalFooter, ChipModalHeader, toast import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { useRouter } from 'next/navigation' -import type { MyInvitation } from '@/lib/api/contracts/invitations' +import type { ViewerInvitation } from '@/lib/api/contracts/invitations' import { getInvitationErrorMessage } from '@/lib/invitations/error-messages' import { useAcceptMyInvitation, useDeclineMyInvitation, - useMyPendingInvitations, + usePendingInvitationsForViewer, } from '@/hooks/queries/invitations' const logger = createLogger('ViewInvitationsModal') @@ -19,7 +19,7 @@ const logger = createLogger('ViewInvitationsModal') * invites are labeled by the org (even when workspace grants ride along); * workspace invites by their workspace(s). */ -function invitationLabel(inv: MyInvitation): string { +function invitationLabel(inv: ViewerInvitation): string { if (inv.kind === 'organization') { return inv.organizationName ?? 'Organization' } @@ -32,7 +32,7 @@ function invitationLabel(inv: MyInvitation): string { } /** Secondary line: who invited, plus role (org) or permission (workspace). */ -function invitationSubLabel(inv: MyInvitation): string { +function invitationSubLabel(inv: ViewerInvitation): string { const invitedBy = inv.inviterName ? `Invited by ${inv.inviterName}` : 'Invited' const detail = inv.kind === 'organization' ? inv.role : inv.grants[0]?.permission return detail ? `${invitedBy} · ${detail}` : invitedBy @@ -51,14 +51,14 @@ interface ViewInvitationsModalProps { * the joined workspace; declining keeps it open for the remaining rows. */ export function ViewInvitationsModal({ open, onOpenChange }: ViewInvitationsModalProps) { - const { data: invitations } = useMyPendingInvitations(open) + const { data: invitations } = usePendingInvitationsForViewer() const acceptInvitation = useAcceptMyInvitation() const declineInvitation = useDeclineMyInvitation() const router = useRouter() const isBusy = acceptInvitation.isPending || declineInvitation.isPending - const handleAccept = async (inv: MyInvitation) => { + const handleAccept = async (inv: ViewerInvitation) => { try { const result = await acceptInvitation.mutateAsync({ invitationId: inv.id, @@ -79,7 +79,7 @@ export function ViewInvitationsModal({ open, onOpenChange }: ViewInvitationsModa } } - const handleDecline = async (inv: MyInvitation) => { + const handleDecline = async (inv: ViewerInvitation) => { try { await declineInvitation.mutateAsync({ invitationId: inv.id }) } catch (error) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx index 82db252aea0..773e4f92520 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx @@ -30,7 +30,7 @@ import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/ import { CreateWorkspaceModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal' import { ViewInvitationsMenuItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-menu-item' import { ViewInvitationsModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal' -import { invitationKeys } from '@/hooks/queries/invitations' +import { invitationKeys } from '@/hooks/queries/utils/invitation-keys' import { type Workspace, type WorkspaceCreationPolicy, @@ -489,7 +489,7 @@ function WorkspaceHeaderImpl({ // or a fresh pending invitation, appears without a page refresh // (these are app-wide queries with no focus refetch on the web). void queryClient.refetchQueries({ queryKey: workspaceKeys.lists(), stale: true }) - void queryClient.refetchQueries({ queryKey: invitationKeys.mine(), stale: true }) + void queryClient.refetchQueries({ queryKey: invitationKeys.viewer(), stale: true }) } setIsWorkspaceMenuOpen(open) if (open && showSearch) { diff --git a/apps/sim/hooks/queries/invitations.ts b/apps/sim/hooks/queries/invitations.ts index 394ff0b9113..d3f68928f68 100644 --- a/apps/sim/hooks/queries/invitations.ts +++ b/apps/sim/hooks/queries/invitations.ts @@ -11,41 +11,27 @@ import { cancelInvitationContract, getInvitationContract, type InvitationJoinOutcome, - listMyInvitationsContract, + listViewerInvitationsContract, listWorkspaceInvitationsContract, - type MyInvitation, type PendingInvitationRow, rejectInvitationContract, removeWorkspaceMemberContract, resendInvitationContract, + type ViewerInvitation, } from '@/lib/api/contracts/invitations' import { updateWorkspacePermissionsContract } from '@/lib/api/contracts/workspaces' import { organizationKeys } from '@/hooks/queries/organization' import { refreshSessionQuery } from '@/hooks/queries/session' import { subscriptionKeys } from '@/hooks/queries/subscription' import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' +import { + INVITATION_DETAILS_STALE_TIME, + invitationKeys, + VIEWER_INVITATIONS_STALE_TIME, + WORKSPACE_INVITATION_LIST_STALE_TIME, +} from '@/hooks/queries/utils/invitation-keys' import { workspaceKeys } from '@/hooks/queries/workspace' -export const invitationKeys = { - all: ['invitations'] as const, - lists: () => [...invitationKeys.all, 'list'] as const, - list: (workspaceId: string) => [...invitationKeys.lists(), workspaceId] as const, - details: () => [...invitationKeys.all, 'detail'] as const, - /** - * Scoped by viewer: the response is viewer-dependent (the join preview is - * invitee-only, and authorization differs per account), so a cached entry - * must never be reused across a sign-out/sign-in on the same invite link — - * doing so would let a stale "nothing moves" preview become the disclosure - * basis for a different user. - */ - detail: (invitationId: string, token: string | null, viewerId: string | null) => - [...invitationKeys.details(), invitationId, token ?? '', viewerId ?? ''] as const, - mine: () => [...invitationKeys.all, 'mine'] as const, -} - -export const WORKSPACE_INVITATION_LIST_STALE_TIME = 30 * 1000 -export const INVITATION_DETAILS_STALE_TIME = 30 * 1000 - async function fetchInvitationDetails( invitationId: string, token: string | null, @@ -123,25 +109,22 @@ export function usePendingInvitations(workspaceId: string | undefined) { }) } -export const MY_INVITATIONS_STALE_TIME = 30 * 1000 - -async function fetchMyPendingInvitations(signal?: AbortSignal): Promise { - const data = await requestJson(listMyInvitationsContract, { signal }) +async function fetchPendingInvitationsForViewer(signal?: AbortSignal): Promise { + const data = await requestJson(listViewerInvitationsContract, { signal }) return data.invitations } /** * Pending invitations addressed to the signed-in account, for the workspace - * switcher's Invitations section. The switcher menu-item mounts this on - * dropdown open (so it fetches then); the modal passes `enabled: open` so it - * does not fetch on every app load for the majority of users who have none. + * switcher's Invitations section. Hydrated by the sidebar's server prefetch, so + * the switcher's "View invitations" entry is present the moment the menu opens + * rather than appearing a beat later; opening the menu only revalidates. */ -export function useMyPendingInvitations(enabled = true) { +export function usePendingInvitationsForViewer() { return useQuery({ - queryKey: invitationKeys.mine(), - queryFn: ({ signal }) => fetchMyPendingInvitations(signal), - enabled, - staleTime: MY_INVITATIONS_STALE_TIME, + queryKey: invitationKeys.viewer(), + queryFn: ({ signal }) => fetchPendingInvitationsForViewer(signal), + staleTime: VIEWER_INVITATIONS_STALE_TIME, }) } @@ -191,7 +174,7 @@ export function useAcceptMyInvitation() { // (expired / already-processed since the list loaded) drops instead of // lingering as a re-clickable dead row. onSettled: () => { - queryClient.invalidateQueries({ queryKey: invitationKeys.mine() }) + queryClient.invalidateQueries({ queryKey: invitationKeys.viewer() }) }, }) } @@ -204,7 +187,7 @@ export function useDeclineMyInvitation() { mutationFn: async ({ invitationId }: { invitationId: string }) => requestJson(rejectInvitationContract, { params: { id: invitationId }, body: {} }), onSettled: () => { - queryClient.invalidateQueries({ queryKey: invitationKeys.mine() }) + queryClient.invalidateQueries({ queryKey: invitationKeys.viewer() }) }, }) } diff --git a/apps/sim/hooks/queries/utils/invitation-keys.ts b/apps/sim/hooks/queries/utils/invitation-keys.ts new file mode 100644 index 00000000000..311a651b3f7 --- /dev/null +++ b/apps/sim/hooks/queries/utils/invitation-keys.ts @@ -0,0 +1,29 @@ +/** + * Lives in this standalone module — like {@link file://./pinned-item-keys.ts} and + * {@link file://./folder-keys.ts} — so the sidebar's server prefetch can hydrate the + * viewer's pending invitations without importing `@/hooks/queries/invitations`, which + * pulls the emcn toast surface and the whole invitation-mutation machinery in with it. + */ + +export const invitationKeys = { + all: ['invitations'] as const, + lists: () => [...invitationKeys.all, 'list'] as const, + list: (workspaceId: string) => [...invitationKeys.lists(), workspaceId] as const, + details: () => [...invitationKeys.all, 'detail'] as const, + /** + * Scoped by viewer: the response is viewer-dependent (the join preview is + * invitee-only, and authorization differs per account), so a cached entry + * must never be reused across a sign-out/sign-in on the same invite link — + * doing so would let a stale "nothing moves" preview become the disclosure + * basis for a different user. + */ + detail: (invitationId: string, token: string | null, viewerId: string | null) => + [...invitationKeys.details(), invitationId, token ?? '', viewerId ?? ''] as const, + viewer: () => [...invitationKeys.all, 'viewer'] as const, +} + +export const WORKSPACE_INVITATION_LIST_STALE_TIME = 30 * 1000 +export const INVITATION_DETAILS_STALE_TIME = 30 * 1000 + +/** Shared with the server prefetch so a hydrated list and a client fetch never disagree. */ +export const VIEWER_INVITATIONS_STALE_TIME = 30 * 1000 diff --git a/apps/sim/lib/api/contracts/invitations.ts b/apps/sim/lib/api/contracts/invitations.ts index a3a977e11cd..b7245398f44 100644 --- a/apps/sim/lib/api/contracts/invitations.ts +++ b/apps/sim/lib/api/contracts/invitations.ts @@ -242,19 +242,19 @@ export const getInvitationContract = defineRouteContract({ * when the preview could not be computed; the client then shows the generic * notice rather than treating it as "nothing moves". */ -export const myInvitationSchema = invitationDetailsSchema.extend({ +export const viewerInvitationSchema = invitationDetailsSchema.extend({ joinPreview: invitationJoinPreviewSchema.nullable(), }) -export type MyInvitation = z.output +export type ViewerInvitation = z.output -export const listMyInvitationsContract = defineRouteContract({ +export const listViewerInvitationsContract = defineRouteContract({ method: 'GET', path: '/api/invitations', response: { mode: 'json', schema: z.object({ - invitations: z.array(myInvitationSchema), + invitations: z.array(viewerInvitationSchema), }), }, }) diff --git a/apps/sim/lib/invitations/pending.ts b/apps/sim/lib/invitations/pending.ts new file mode 100644 index 00000000000..b14b647dafb --- /dev/null +++ b/apps/sim/lib/invitations/pending.ts @@ -0,0 +1,70 @@ +import { createLogger } from '@sim/logger' +import type { ViewerInvitation } from '@/lib/api/contracts/invitations' +import { getInvitationJoinPreview, listPendingInvitationsForEmail } from '@/lib/invitations/core' + +const logger = createLogger('PendingInvitations') + +/** + * The invitee-facing pending-invitation list behind the workspace switcher's + * Invitations section, assembled once for both the `GET /api/invitations` route + * and the sidebar's server prefetch — so the prefetched cache entry and a client + * fetch can never disagree about the shape stored under `invitationKeys.viewer()`. + * + * Rows deliberately exclude the token: acceptance here is session-bound (email + * match), which is what makes it immune to the wrong-browser-account problem. + */ +export async function listPendingInvitationsForViewer( + userId: string, + email: string +): Promise { + const invitations = await listPendingInvitationsForEmail(email) + + /** + * Each row carries what accepting it will actually do, so the client can echo + * `disclosedWorkspaceIds` on accept — the consent contract the emailed + * `/invite` page honours. A preview failure degrades to `null` rather than + * hiding the invitation. + * + * Sequential on purpose: each preview issues several queries, and the sidebar + * prefetch calls this on every workspace page load. 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. + */ + const previews: Array> | null> = [] + for (const inv of invitations) { + try { + previews.push(await getInvitationJoinPreview(userId, inv)) + } catch (previewError) { + logger.warn('Failed to compute join preview for pending invitation', { + invitationId: inv.id, + error: previewError, + }) + previews.push(null) + } + } + + return invitations.map( + (inv, index) => + ({ + id: inv.id, + kind: inv.kind, + email: inv.email, + organizationId: inv.organizationId, + organizationName: inv.organizationName, + membershipIntent: inv.membershipIntent, + role: inv.role, + status: inv.status, + expiresAt: inv.expiresAt.toISOString(), + createdAt: inv.createdAt.toISOString(), + inviterName: inv.inviterName, + inviterEmail: inv.inviterEmail, + grants: inv.grants.map((grant) => ({ + workspaceId: grant.workspaceId, + workspaceName: grant.workspaceName, + permission: grant.permission, + })), + joinPreview: previews[index], + }) satisfies ViewerInvitation + ) +} diff --git a/apps/sim/lib/knowledge/queries.ts b/apps/sim/lib/knowledge/queries.ts new file mode 100644 index 00000000000..e82c7facc7f --- /dev/null +++ b/apps/sim/lib/knowledge/queries.ts @@ -0,0 +1,28 @@ +import type { KnowledgeBaseData } from '@/lib/api/contracts/knowledge' +import { getKnowledgeBases, type KnowledgeBaseScope } from '@/lib/knowledge/service' + +/** + * Lists a viewer's knowledge bases in the wire shape the `/api/knowledge` contract declares. + * + * Shared by `GET /api/knowledge` and the Knowledge page's server prefetch so a hydrated + * cache entry and a client fetch cannot disagree. + * + * Dates are serialized explicitly: `knowledgeBaseDataSchema` types every date as + * `wireDateSchema` (`z.string()`), so caching raw `Date`s would violate the declared type + * and silently become strings on the first refetch. + */ +export async function listKnowledgeBasesForViewer( + userId: string, + workspaceId?: string | null, + scope: KnowledgeBaseScope = 'active' +): Promise { + const bases = await getKnowledgeBases(userId, workspaceId, scope) + return bases.map((base) => ({ + ...base, + /** Spread so the closed `ChunkingConfig` interface satisfies the schema's open shape. */ + chunkingConfig: { ...base.chunkingConfig }, + createdAt: base.createdAt.toISOString(), + updatedAt: base.updatedAt.toISOString(), + deletedAt: base.deletedAt?.toISOString() ?? null, + })) +} diff --git a/apps/sim/lib/pinned-items/queries.ts b/apps/sim/lib/pinned-items/queries.ts new file mode 100644 index 00000000000..cfffbb8e687 --- /dev/null +++ b/apps/sim/lib/pinned-items/queries.ts @@ -0,0 +1,62 @@ +import { db, pinnedItem } from '@sim/db' +import { and, eq, ne } from 'drizzle-orm' +import { type PinnedItemApi, pinnedResourceTypeSchema } from '@/lib/api/contracts' +import type { PinnedResourceType } from '@/lib/api/contracts/pinned-items' +import { filterToActiveResources } from '@/lib/pinned-items/resources' + +/** + * Narrows a stored row to the wire shape, dropping any row whose `resourceType` this build does + * not recognise. + * + * `pinned_item.resource_type` is plain `text` — deliberately, so the set of pinnable kinds can + * grow — while the contract is a closed enum. During a rolling deploy an older pod can therefore + * read a pin a newer one wrote. Returning it would fail response validation and take the WHOLE + * list down rather than the single row, so the unknown kind is skipped instead. + * + * `filterToActiveResources` already drops these as a side effect of not having a table to look + * them up in; this makes the guarantee explicit and compiler-checked at the wire boundary. + */ +function toPinnedItemApi(row: typeof pinnedItem.$inferSelect): PinnedItemApi | null { + const resourceType = pinnedResourceTypeSchema.safeParse(row.resourceType) + if (!resourceType.success) return null + return { ...row, resourceType: resourceType.data, pinnedAt: row.pinnedAt.toISOString() } +} + +/** + * Lists a viewer's pinned items in a workspace, optionally filtered to one `resourceType`, + * already narrowed to the wire shape and to resources that still exist. + * + * Shared by `GET /api/pinned-items` and the resource-list prefetch so a hydrated cache entry + * and a client fetch cannot disagree — pinned ids are the list's primary sort key, so any + * drift reorders the list on the first refetch. + * + * Callers are responsible for authorizing the viewer against `workspaceId` first. + */ +export async function listPinnedItemsForViewer( + userId: string, + workspaceId: string, + resourceType?: PinnedResourceType +): Promise { + const rows = await db + .select() + .from(pinnedItem) + .where( + and( + eq(pinnedItem.userId, userId), + eq(pinnedItem.workspaceId, workspaceId), + /** + * A `workspace` pin stores `workspaceId === resourceId`, so it would otherwise + * appear in this workspace's unscoped listing as a resource *inside* itself. + * It is read from the workspace-list payload instead, so it is excluded here + * rather than left for a future unscoped caller to mistake for a real resource. + */ + resourceType + ? eq(pinnedItem.resourceType, resourceType) + : ne(pinnedItem.resourceType, 'workspace') + ) + ) + + const activeRows = await filterToActiveResources(rows, workspaceId) + + return activeRows.map(toPinnedItemApi).filter((item): item is PinnedItemApi => item !== null) +} diff --git a/apps/sim/lib/workspace-files/queries.ts b/apps/sim/lib/workspace-files/queries.ts new file mode 100644 index 00000000000..5b3a605eb7c --- /dev/null +++ b/apps/sim/lib/workspace-files/queries.ts @@ -0,0 +1,23 @@ +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/Home prefetches so a hydrated cache entry + * and a client fetch cannot disagree. + * + * Callers are responsible for authorizing the viewer against `workspaceId` first. + */ +export async function listWorkspaceFilesWithShares( + workspaceId: string, + scope: WorkspaceFileScope = 'active' +) { + const [files, shares] = await Promise.all([ + listWorkspaceFiles(workspaceId, { scope }), + getWorkspaceShares('file', workspaceId), + ]) + return files.map((file) => ({ ...file, share: shares.get(file.id) ?? null })) +} From 1862a84b8d49c4c7c8c569298dec462a370ca340 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 7 Aug 2026 21:39:36 -0700 Subject: [PATCH 2/4] fix(tables): give the tables prefetch the same wire shape the route returns GET /api/table does not return listTables rows: it drops metadata, runs every column through normalizeColumn, serializes the three dates, and defaults the job fields. The prefetch called listTables directly, so a hydrated entry held un-normalized columns plus a field the client never sees, and swapped them out on the first refetch. Extracts listTablesForWorkspace so the route and the prefetch produce one shape, matching what files, knowledge and pinned items already do here. --- apps/sim/app/api/table/route.ts | 44 ++++-------------- .../[workspaceId]/lib/prefetch.test.ts | 14 +++--- .../[workspaceId]/tables/prefetch.ts | 9 ++-- apps/sim/lib/table/queries.ts | 45 +++++++++++++++++++ 4 files changed, 66 insertions(+), 46 deletions(-) create mode 100644 apps/sim/lib/table/queries.ts diff --git a/apps/sim/app/api/table/route.ts b/apps/sim/app/api/table/route.ts index 2522cddb7c6..d49cceb73d8 100644 --- a/apps/sim/app/api/table/route.ts +++ b/apps/sim/app/api/table/route.ts @@ -11,10 +11,10 @@ import { captureServerEvent } from '@/lib/posthog/server' import { createTable, getWorkspaceTableLimits, - listTables, type TableSchema, type TableScope, } from '@/lib/table' +import { listTablesForWorkspace } from '@/lib/table/queries' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { normalizeColumn } from '@/app/api/table/utils' @@ -204,46 +204,20 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - const tables = await listTables(params.workspaceId, { scope: params.scope as TableScope }) - - logger.info(`[${requestId}] Listed ${tables.length} tables in workspace ${params.workspaceId}`) + const responseTables = await listTablesForWorkspace( + params.workspaceId, + params.scope as TableScope + ) - const responseTables = tables.map((t) => { - const schemaData = t.schema as TableSchema - return { - id: t.id, - name: t.name, - description: t.description, - schema: { - columns: schemaData.columns.map(normalizeColumn), - }, - rowCount: t.rowCount, - maxRows: t.maxRows, - locks: t.locks, - workspaceId: t.workspaceId, - folderId: t.folderId ?? null, - createdBy: t.createdBy, - createdAt: t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt), - updatedAt: t.updatedAt instanceof Date ? t.updatedAt.toISOString() : String(t.updatedAt), - archivedAt: - t.archivedAt instanceof Date - ? t.archivedAt.toISOString() - : t.archivedAt - ? String(t.archivedAt) - : null, - jobStatus: t.jobStatus ?? null, - jobId: t.jobId ?? null, - jobType: t.jobType ?? null, - jobError: t.jobError ?? null, - jobRowsProcessed: t.jobRowsProcessed ?? 0, - } - }) + logger.info( + `[${requestId}] Listed ${responseTables.length} tables in workspace ${params.workspaceId}` + ) return NextResponse.json({ success: true, data: { tables: responseTables, - totalCount: tables.length, + totalCount: responseTables.length, }, }) } catch (error) { diff --git a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts index 5d495833777..9845fccd391 100644 --- a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts @@ -10,7 +10,7 @@ const { mockGetWorkspaceMemberProfiles, mockListFoldersForWorkspace, mockListPinnedItemsForViewer, - mockListTables, + mockListTablesForWorkspace, mockListWorkspaceFileFolders, mockListWorkspaceFilesWithShares, } = vi.hoisted(() => ({ @@ -19,7 +19,7 @@ const { mockGetWorkspaceMemberProfiles: vi.fn(), mockListFoldersForWorkspace: vi.fn(), mockListPinnedItemsForViewer: vi.fn(), - mockListTables: vi.fn(), + mockListTablesForWorkspace: vi.fn(), mockListWorkspaceFileFolders: vi.fn(), mockListWorkspaceFilesWithShares: vi.fn(), })) @@ -38,7 +38,7 @@ vi.mock('@/lib/workspace-files/queries', () => ({ vi.mock('@/lib/uploads/contexts/workspace', () => ({ listWorkspaceFileFolders: mockListWorkspaceFileFolders, })) -vi.mock('@/lib/table', () => ({ listTables: mockListTables })) +vi.mock('@/lib/table/queries', () => ({ listTablesForWorkspace: mockListTablesForWorkspace })) vi.mock('@/lib/knowledge/queries', () => ({ listKnowledgeBasesForViewer: mockListKnowledgeBasesForViewer, })) @@ -75,7 +75,7 @@ describe('workspace list prefetches', () => { mockListFoldersForWorkspace.mockResolvedValue([]) mockListWorkspaceFilesWithShares.mockResolvedValue([]) mockListWorkspaceFileFolders.mockResolvedValue([]) - mockListTables.mockResolvedValue([]) + mockListTablesForWorkspace.mockResolvedValue([]) mockListKnowledgeBasesForViewer.mockResolvedValue([]) }) @@ -95,12 +95,12 @@ describe('workspace list prefetches', () => { describe('prefetchTables', () => { it('primes the exact key useTablesList reads', async () => { const tables = [{ id: 't-1' }] - mockListTables.mockResolvedValue(tables) + mockListTablesForWorkspace.mockResolvedValue(tables) const client = makeClient() await prefetchTables(client, WORKSPACE_ID, USER_ID) - expect(mockListTables).toHaveBeenCalledWith(WORKSPACE_ID, { scope: 'active' }) + expect(mockListTablesForWorkspace).toHaveBeenCalledWith(WORKSPACE_ID, 'active') expect(client.getQueryData(tableKeys.list(WORKSPACE_ID, 'active'))).toEqual(tables) }) }) @@ -177,7 +177,7 @@ describe('workspace list prefetches', () => { expect(client.getQueryCache().getAll()).toHaveLength(0) expect(mockListWorkspaceFilesWithShares).not.toHaveBeenCalled() - expect(mockListTables).not.toHaveBeenCalled() + expect(mockListTablesForWorkspace).not.toHaveBeenCalled() expect(mockListKnowledgeBasesForViewer).not.toHaveBeenCalled() expect(mockListPinnedItemsForViewer).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts index bd105db76c5..39cac1cfe85 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts @@ -1,6 +1,6 @@ import type { QueryClient } from '@tanstack/react-query' import { listFoldersForWorkspace } from '@/lib/folders/queries' -import { listTables } from '@/lib/table' +import { listTablesForWorkspace } from '@/lib/table/queries' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { prefetchResourceListChrome } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome' import { FOLDER_LIST_STALE_TIME, folderKeys, mapFolder } from '@/hooks/queries/utils/folder-keys' @@ -14,8 +14,9 @@ import { TABLE_LIST_STALE_TIME, tableKeys } from '@/hooks/queries/utils/table-ke * only placed correctly relative to the folder rows it sits beside, so * prefetching one without the other still flashes an ungrouped list. * - * Folders are mapped with the same `mapFolder` the hook applies so the hydrated entry - * matches a client fetch exactly. + * `listTablesForWorkspace` returns the same wire shape `GET /api/table` does, and folders + * are mapped with the same `mapFolder` the hook applies — so both hydrated entries match a + * client fetch exactly. */ export async function prefetchTables( queryClient: QueryClient, @@ -28,7 +29,7 @@ export async function prefetchTables( await Promise.all([ queryClient.prefetchQuery({ queryKey: tableKeys.list(workspaceId, 'active'), - queryFn: () => listTables(workspaceId, { scope: 'active' }), + queryFn: () => listTablesForWorkspace(workspaceId, 'active'), staleTime: TABLE_LIST_STALE_TIME, }), queryClient.prefetchQuery({ diff --git a/apps/sim/lib/table/queries.ts b/apps/sim/lib/table/queries.ts new file mode 100644 index 00000000000..2dca3ed37a8 --- /dev/null +++ b/apps/sim/lib/table/queries.ts @@ -0,0 +1,45 @@ +import { listTables, type TableScope } from '@/lib/table/service' +import type { TableDefinition, TableSchema } from '@/lib/table/types' +import { normalizeColumn } from '@/app/api/table/utils' + +/** Serializes a stored date to the ISO string the wire carries. */ +function toWireDate(value: Date | string): string { + return value instanceof Date ? value.toISOString() : String(value) +} + +/** + * Lists a workspace's tables in the wire shape `GET /api/table` returns. + * + * Shared by that route and the Tables page's server prefetch so a hydrated cache entry and a + * client fetch cannot disagree. The shaping is not incidental: the route drops `metadata`, + * runs every column through {@link normalizeColumn}, serializes the three dates, and defaults + * the job fields — so caching raw `listTables` rows would hydrate un-normalized columns and a + * field the client never sees, then swap them out on the first refetch. + */ +export async function listTablesForWorkspace( + workspaceId: string, + scope: TableScope = 'active' +): Promise { + const tables = await listTables(workspaceId, { scope }) + + return tables.map((table) => ({ + id: table.id, + name: table.name, + description: table.description, + schema: { columns: (table.schema as TableSchema).columns.map(normalizeColumn) }, + rowCount: table.rowCount, + maxRows: table.maxRows, + locks: table.locks, + workspaceId: table.workspaceId, + folderId: table.folderId ?? null, + createdBy: table.createdBy, + createdAt: toWireDate(table.createdAt), + updatedAt: toWireDate(table.updatedAt), + archivedAt: table.archivedAt ? toWireDate(table.archivedAt) : null, + jobStatus: table.jobStatus ?? null, + jobId: table.jobId ?? null, + jobType: table.jobType ?? null, + jobError: table.jobError ?? null, + jobRowsProcessed: table.jobRowsProcessed ?? 0, + })) +} From eac836bb0ae461510d15714bfe86520f6c144ea8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 7 Aug 2026 21:59:27 -0700 Subject: [PATCH 3/4] fix(prefetch): keep the tables list on its route and pin the files wire shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check:tool-registry-boundary rejected the tables page: lib/table/service transitively imports the executor, so reading listTables from a page.tsx pulls the tool registry (~4,700 modules) into that route's graph. Breaking the chain means relocating stripGroupDeps, latestJobsForTables and pendingDeleteMask out of modules that import the executor — a lib/table refactor that wants its own review rather than riding along here. The tables list therefore keeps going through its route, which reshapes every row anyway, while its folders and list chrome read the data layer. prefetchInternalJson comes back for that one caller and now logs before it throws: prefetchQuery swallows the rejection and shouldDehydrateQuery drops the errored entry, which is exactly how the original bug stayed invisible. Also parses listWorkspaceFilesWithShares through the route contract's response schema. listWorkspaceFiles returns contentUpdatedAt, which the schema does not declare and does not pass through, so the prefetch was caching a field a client fetch never has and that vanished on the next refetch. --- apps/sim/app/api/table/route.ts | 44 ++++++++--- .../workspace/[workspaceId]/files/prefetch.ts | 9 +-- .../[workspaceId]/knowledge/prefetch.ts | 5 +- .../lib/prefetch-internal-fetch.ts | 32 ++++++++ .../[workspaceId]/lib/prefetch.test.ts | 18 +++-- .../[workspaceId]/tables/prefetch.ts | 16 ++-- .../view-invitations-menu-item.tsx | 4 +- .../hooks/queries/utils/invitation-keys.ts | 2 - apps/sim/lib/invitations/pending.ts | 3 +- apps/sim/lib/knowledge/queries.ts | 9 +-- apps/sim/lib/pinned-items/queries.ts | 7 +- apps/sim/lib/table/queries.ts | 45 ----------- apps/sim/lib/workspace-files/queries.test.ts | 75 +++++++++++++++++++ apps/sim/lib/workspace-files/queries.ts | 14 +++- 14 files changed, 186 insertions(+), 97 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/lib/prefetch-internal-fetch.ts delete mode 100644 apps/sim/lib/table/queries.ts create mode 100644 apps/sim/lib/workspace-files/queries.test.ts diff --git a/apps/sim/app/api/table/route.ts b/apps/sim/app/api/table/route.ts index d49cceb73d8..2522cddb7c6 100644 --- a/apps/sim/app/api/table/route.ts +++ b/apps/sim/app/api/table/route.ts @@ -11,10 +11,10 @@ import { captureServerEvent } from '@/lib/posthog/server' import { createTable, getWorkspaceTableLimits, + listTables, type TableSchema, type TableScope, } from '@/lib/table' -import { listTablesForWorkspace } from '@/lib/table/queries' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { normalizeColumn } from '@/app/api/table/utils' @@ -204,20 +204,46 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - const responseTables = await listTablesForWorkspace( - params.workspaceId, - params.scope as TableScope - ) + const tables = await listTables(params.workspaceId, { scope: params.scope as TableScope }) - logger.info( - `[${requestId}] Listed ${responseTables.length} tables in workspace ${params.workspaceId}` - ) + logger.info(`[${requestId}] Listed ${tables.length} tables in workspace ${params.workspaceId}`) + + const responseTables = tables.map((t) => { + const schemaData = t.schema as TableSchema + return { + id: t.id, + name: t.name, + description: t.description, + schema: { + columns: schemaData.columns.map(normalizeColumn), + }, + rowCount: t.rowCount, + maxRows: t.maxRows, + locks: t.locks, + workspaceId: t.workspaceId, + folderId: t.folderId ?? null, + createdBy: t.createdBy, + createdAt: t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt), + updatedAt: t.updatedAt instanceof Date ? t.updatedAt.toISOString() : String(t.updatedAt), + archivedAt: + t.archivedAt instanceof Date + ? t.archivedAt.toISOString() + : t.archivedAt + ? String(t.archivedAt) + : null, + jobStatus: t.jobStatus ?? null, + jobId: t.jobId ?? null, + jobType: t.jobType ?? null, + jobError: t.jobError ?? null, + jobRowsProcessed: t.jobRowsProcessed ?? 0, + } + }) return NextResponse.json({ success: true, data: { tables: responseTables, - totalCount: responseTables.length, + totalCount: tables.length, }, }) } catch (error) { diff --git a/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts index 403d85758fc..f8b25930873 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts @@ -20,13 +20,8 @@ import { * `useWorkspaceFileFolders`) use (scope `active`), so the browser paints populated * on first render. * - * Calls the data layer directly — the same functions the API routes use — matching - * `prefetchWorkspaceSidebar`. A rejection here is swallowed by `prefetchQuery` and the - * errored entry dropped by `shouldDehydrateQuery`, so one list failing must not take - * its siblings down with it. - * - * Membership is verified once rather than per-list. Without access nothing is cached, - * so the client fetch reaches the route and gets the real 403. + * Without workspace access nothing is cached, so the client fetch reaches the route and + * gets the real 403. */ export async function prefetchFilesBrowser( queryClient: QueryClient, diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts index 0a99698a685..7b69c12edea 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts @@ -16,9 +16,8 @@ import { KNOWLEDGE_BASE_LIST_STALE_TIME, knowledgeKeys } from '@/hooks/queries/u * beside, so prefetching one without the other still flashes an ungrouped list — and a * `?folderId=` deep link renders an empty breadcrumb until the folders arrive. * - * `listKnowledgeBasesForViewer` is viewer-scoped and returns the contract's wire shape, - * and folders go through the same `mapFolder` the hook applies — so both hydrated - * entries match a client fetch exactly. + * Folders are mapped with the same `mapFolder` the hook applies, so both hydrated entries + * match a client fetch exactly. */ export async function prefetchKnowledgeBases( queryClient: QueryClient, diff --git a/apps/sim/app/workspace/[workspaceId]/lib/prefetch-internal-fetch.ts b/apps/sim/app/workspace/[workspaceId]/lib/prefetch-internal-fetch.ts new file mode 100644 index 00000000000..1d5b4d3eb02 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/lib/prefetch-internal-fetch.ts @@ -0,0 +1,32 @@ +import { createLogger } from '@sim/logger' +import { headers } from 'next/headers' +import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' + +const logger = createLogger('PrefetchInternalFetch') + +/** + * Server-side GET against an internal `/api` route, forwarding the incoming request's cookie + * so the route authenticates as the current user. + * + * Only the tables list still needs this. `lib/table/service` transitively imports the + * executor, so reading `listTables` from a `page.tsx` pulls the tool registry into that + * route's graph and `bun run check:tool-registry-boundary` rejects it. + * + * Failures are logged: `prefetchQuery` swallows the rejection and `shouldDehydrateQuery` + * drops the errored entry, so without this a failure silently ships a page missing that list. + */ +export async function prefetchInternalJson(path: string): Promise { + const cookie = (await headers()).get('cookie') + // boundary-raw-fetch: server-side RSC prefetch forwarding the session cookie to an internal API route; requestJson is client-only and cannot run here + const response = await fetch(`${getInternalApiBaseUrl()}${path}`, { + headers: cookie ? { cookie } : {}, + }) + if (!response.ok) { + logger.error('Prefetch request failed; the list will client-fetch instead', { + path, + status: response.status, + }) + throw new Error(`Prefetch failed for ${path}: ${response.status}`) + } + return response.json() as Promise +} diff --git a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts index 9845fccd391..793462dd6e2 100644 --- a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts @@ -10,7 +10,7 @@ const { mockGetWorkspaceMemberProfiles, mockListFoldersForWorkspace, mockListPinnedItemsForViewer, - mockListTablesForWorkspace, + mockPrefetchInternalJson, mockListWorkspaceFileFolders, mockListWorkspaceFilesWithShares, } = vi.hoisted(() => ({ @@ -19,7 +19,7 @@ const { mockGetWorkspaceMemberProfiles: vi.fn(), mockListFoldersForWorkspace: vi.fn(), mockListPinnedItemsForViewer: vi.fn(), - mockListTablesForWorkspace: vi.fn(), + mockPrefetchInternalJson: vi.fn(), mockListWorkspaceFileFolders: vi.fn(), mockListWorkspaceFilesWithShares: vi.fn(), })) @@ -38,7 +38,9 @@ vi.mock('@/lib/workspace-files/queries', () => ({ vi.mock('@/lib/uploads/contexts/workspace', () => ({ listWorkspaceFileFolders: mockListWorkspaceFileFolders, })) -vi.mock('@/lib/table/queries', () => ({ listTablesForWorkspace: mockListTablesForWorkspace })) +vi.mock('@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch', () => ({ + prefetchInternalJson: mockPrefetchInternalJson, +})) vi.mock('@/lib/knowledge/queries', () => ({ listKnowledgeBasesForViewer: mockListKnowledgeBasesForViewer, })) @@ -75,7 +77,7 @@ describe('workspace list prefetches', () => { mockListFoldersForWorkspace.mockResolvedValue([]) mockListWorkspaceFilesWithShares.mockResolvedValue([]) mockListWorkspaceFileFolders.mockResolvedValue([]) - mockListTablesForWorkspace.mockResolvedValue([]) + mockPrefetchInternalJson.mockResolvedValue({ data: { tables: [] } }) mockListKnowledgeBasesForViewer.mockResolvedValue([]) }) @@ -95,12 +97,14 @@ describe('workspace list prefetches', () => { describe('prefetchTables', () => { it('primes the exact key useTablesList reads', async () => { const tables = [{ id: 't-1' }] - mockListTablesForWorkspace.mockResolvedValue(tables) + mockPrefetchInternalJson.mockResolvedValue({ data: { tables } }) const client = makeClient() await prefetchTables(client, WORKSPACE_ID, USER_ID) - expect(mockListTablesForWorkspace).toHaveBeenCalledWith(WORKSPACE_ID, 'active') + expect(mockPrefetchInternalJson).toHaveBeenCalledWith( + `/api/table?workspaceId=${WORKSPACE_ID}&scope=active` + ) expect(client.getQueryData(tableKeys.list(WORKSPACE_ID, 'active'))).toEqual(tables) }) }) @@ -177,7 +181,7 @@ describe('workspace list prefetches', () => { expect(client.getQueryCache().getAll()).toHaveLength(0) expect(mockListWorkspaceFilesWithShares).not.toHaveBeenCalled() - expect(mockListTablesForWorkspace).not.toHaveBeenCalled() + expect(mockPrefetchInternalJson).not.toHaveBeenCalled() expect(mockListKnowledgeBasesForViewer).not.toHaveBeenCalled() expect(mockListPinnedItemsForViewer).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts index 39cac1cfe85..a232e085ee3 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts @@ -1,7 +1,8 @@ import type { QueryClient } from '@tanstack/react-query' import { listFoldersForWorkspace } from '@/lib/folders/queries' -import { listTablesForWorkspace } from '@/lib/table/queries' +import type { TableDefinition } from '@/lib/table' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' import { prefetchResourceListChrome } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome' import { FOLDER_LIST_STALE_TIME, folderKeys, mapFolder } from '@/hooks/queries/utils/folder-keys' import { TABLE_LIST_STALE_TIME, tableKeys } from '@/hooks/queries/utils/table-keys' @@ -14,9 +15,9 @@ import { TABLE_LIST_STALE_TIME, tableKeys } from '@/hooks/queries/utils/table-ke * only placed correctly relative to the folder rows it sits beside, so * prefetching one without the other still flashes an ungrouped list. * - * `listTablesForWorkspace` returns the same wire shape `GET /api/table` does, and folders - * are mapped with the same `mapFolder` the hook applies — so both hydrated entries match a - * client fetch exactly. + * The tables list goes through its route rather than the data layer — see + * {@link prefetchInternalJson}. Folders are mapped with the same `mapFolder` the hook + * applies, so that entry matches a client fetch exactly. */ export async function prefetchTables( queryClient: QueryClient, @@ -29,7 +30,12 @@ export async function prefetchTables( await Promise.all([ queryClient.prefetchQuery({ queryKey: tableKeys.list(workspaceId, 'active'), - queryFn: () => listTablesForWorkspace(workspaceId, 'active'), + queryFn: async () => { + const response = await prefetchInternalJson<{ data: { tables: TableDefinition[] } }>( + `/api/table?workspaceId=${workspaceId}&scope=active` + ) + return response.data.tables + }, staleTime: TABLE_LIST_STALE_TIME, }), queryClient.prefetchQuery({ diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-menu-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-menu-item.tsx index 395e760329b..bcac647c6b4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-menu-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-menu-item.tsx @@ -11,9 +11,7 @@ interface ViewInvitationsMenuItemProps { /** * "View invitations" entry in the workspace switcher — rendered only when the - * signed-in account has pending invitations. Mounted inside the dropdown - * content, but the list is hydrated by the sidebar's server prefetch, so the - * entry is there on the frame the menu opens instead of popping in after it. + * signed-in account has pending invitations. */ export function ViewInvitationsMenuItem({ onOpen }: ViewInvitationsMenuItemProps) { const { data: invitations } = usePendingInvitationsForViewer() diff --git a/apps/sim/hooks/queries/utils/invitation-keys.ts b/apps/sim/hooks/queries/utils/invitation-keys.ts index 311a651b3f7..666e567038b 100644 --- a/apps/sim/hooks/queries/utils/invitation-keys.ts +++ b/apps/sim/hooks/queries/utils/invitation-keys.ts @@ -24,6 +24,4 @@ export const invitationKeys = { export const WORKSPACE_INVITATION_LIST_STALE_TIME = 30 * 1000 export const INVITATION_DETAILS_STALE_TIME = 30 * 1000 - -/** Shared with the server prefetch so a hydrated list and a client fetch never disagree. */ export const VIEWER_INVITATIONS_STALE_TIME = 30 * 1000 diff --git a/apps/sim/lib/invitations/pending.ts b/apps/sim/lib/invitations/pending.ts index b14b647dafb..131ecff3ccc 100644 --- a/apps/sim/lib/invitations/pending.ts +++ b/apps/sim/lib/invitations/pending.ts @@ -7,8 +7,7 @@ const logger = createLogger('PendingInvitations') /** * The invitee-facing pending-invitation list behind the workspace switcher's * Invitations section, assembled once for both the `GET /api/invitations` route - * and the sidebar's server prefetch — so the prefetched cache entry and a client - * fetch can never disagree about the shape stored under `invitationKeys.viewer()`. + * and the sidebar's server prefetch so both cache one shape. * * Rows deliberately exclude the token: acceptance here is session-bound (email * match), which is what makes it immune to the wrong-browser-account problem. diff --git a/apps/sim/lib/knowledge/queries.ts b/apps/sim/lib/knowledge/queries.ts index e82c7facc7f..a49eb3b9b9a 100644 --- a/apps/sim/lib/knowledge/queries.ts +++ b/apps/sim/lib/knowledge/queries.ts @@ -4,12 +4,9 @@ import { getKnowledgeBases, type KnowledgeBaseScope } from '@/lib/knowledge/serv /** * Lists a viewer's knowledge bases in the wire shape the `/api/knowledge` contract declares. * - * Shared by `GET /api/knowledge` and the Knowledge page's server prefetch so a hydrated - * cache entry and a client fetch cannot disagree. - * - * Dates are serialized explicitly: `knowledgeBaseDataSchema` types every date as - * `wireDateSchema` (`z.string()`), so caching raw `Date`s would violate the declared type - * and silently become strings on the first refetch. + * Shared by `GET /api/knowledge` and the Knowledge page's server prefetch so both cache one + * shape. Dates are serialized because `knowledgeBaseDataSchema` types them `z.string()` — + * caching raw `Date`s would violate that type and flip to strings on the next refetch. */ export async function listKnowledgeBasesForViewer( userId: string, diff --git a/apps/sim/lib/pinned-items/queries.ts b/apps/sim/lib/pinned-items/queries.ts index cfffbb8e687..5ca03768c3e 100644 --- a/apps/sim/lib/pinned-items/queries.ts +++ b/apps/sim/lib/pinned-items/queries.ts @@ -26,11 +26,10 @@ function toPinnedItemApi(row: typeof pinnedItem.$inferSelect): PinnedItemApi | n * Lists a viewer's pinned items in a workspace, optionally filtered to one `resourceType`, * already narrowed to the wire shape and to resources that still exist. * - * Shared by `GET /api/pinned-items` and the resource-list prefetch so a hydrated cache entry - * and a client fetch cannot disagree — pinned ids are the list's primary sort key, so any - * drift reorders the list on the first refetch. + * Shared by `GET /api/pinned-items` and the resource-list prefetch so both cache one shape — + * pinned ids are the list's primary sort key, so drift reorders the list on the next refetch. * - * Callers are responsible for authorizing the viewer against `workspaceId` first. + * Callers authorize the viewer against `workspaceId` first. */ export async function listPinnedItemsForViewer( userId: string, diff --git a/apps/sim/lib/table/queries.ts b/apps/sim/lib/table/queries.ts deleted file mode 100644 index 2dca3ed37a8..00000000000 --- a/apps/sim/lib/table/queries.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { listTables, type TableScope } from '@/lib/table/service' -import type { TableDefinition, TableSchema } from '@/lib/table/types' -import { normalizeColumn } from '@/app/api/table/utils' - -/** Serializes a stored date to the ISO string the wire carries. */ -function toWireDate(value: Date | string): string { - return value instanceof Date ? value.toISOString() : String(value) -} - -/** - * Lists a workspace's tables in the wire shape `GET /api/table` returns. - * - * Shared by that route and the Tables page's server prefetch so a hydrated cache entry and a - * client fetch cannot disagree. The shaping is not incidental: the route drops `metadata`, - * runs every column through {@link normalizeColumn}, serializes the three dates, and defaults - * the job fields — so caching raw `listTables` rows would hydrate un-normalized columns and a - * field the client never sees, then swap them out on the first refetch. - */ -export async function listTablesForWorkspace( - workspaceId: string, - scope: TableScope = 'active' -): Promise { - const tables = await listTables(workspaceId, { scope }) - - return tables.map((table) => ({ - id: table.id, - name: table.name, - description: table.description, - schema: { columns: (table.schema as TableSchema).columns.map(normalizeColumn) }, - rowCount: table.rowCount, - maxRows: table.maxRows, - locks: table.locks, - workspaceId: table.workspaceId, - folderId: table.folderId ?? null, - createdBy: table.createdBy, - createdAt: toWireDate(table.createdAt), - updatedAt: toWireDate(table.updatedAt), - archivedAt: table.archivedAt ? toWireDate(table.archivedAt) : null, - jobStatus: table.jobStatus ?? null, - jobId: table.jobId ?? null, - jobType: table.jobType ?? null, - jobError: table.jobError ?? null, - jobRowsProcessed: table.jobRowsProcessed ?? 0, - })) -} 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 index 5b3a605eb7c..de812d2925d 100644 --- a/apps/sim/lib/workspace-files/queries.ts +++ b/apps/sim/lib/workspace-files/queries.ts @@ -1,3 +1,4 @@ +import { listWorkspaceFilesContract } from '@/lib/api/contracts/workspace-files' import { getWorkspaceShares } from '@/lib/public-shares/share-manager' import { listWorkspaceFiles, @@ -6,10 +7,13 @@ import { /** * Lists a workspace's files with each file's public share joined on — shared by - * `GET /api/workspaces/[id]/files` and the Files/Home prefetches so a hydrated cache entry - * and a client fetch cannot disagree. + * `GET /api/workspaces/[id]/files` and the Files/Home prefetches so both cache one shape. * - * Callers are responsible for authorizing the viewer against `workspaceId` first. + * 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, @@ -19,5 +23,7 @@ export async function listWorkspaceFilesWithShares( listWorkspaceFiles(workspaceId, { scope }), getWorkspaceShares('file', workspaceId), ]) - return files.map((file) => ({ ...file, share: shares.get(file.id) ?? null })) + const withShares = files.map((file) => ({ ...file, share: shares.get(file.id) ?? null })) + return listWorkspaceFilesContract.response.schema.parse({ success: true, files: withShares }) + .files } From 7e37f2f5a6f9c84bb37a3563d5ff75cc15f1e723 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 7 Aug 2026 22:07:42 -0700 Subject: [PATCH 4/4] perf(invitations): bound the join-preview fan-out off the render critical path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit listPendingInvitationsForViewer computed join previews in a serial loop, which was affordable when the switcher dropdown was the only caller. The sidebar prefetch now calls it on every workspace page render, and each preview issues up to three sequential queries — so a viewer with pending invitations paid roughly three round-trips per invitation before the first byte of every route under the workspace layout. Bounds the fan-out with mapWithConcurrency instead. The preview already degrades to null on failure, which is what makes it safe under a mapper that fails all-or-nothing, and a bound keeps the pooled-connection ceiling the serial loop was protecting. --- apps/sim/lib/invitations/pending.test.ts | 108 +++++++++++++++++++++++ apps/sim/lib/invitations/pending.ts | 47 ++++++---- 2 files changed, 138 insertions(+), 17 deletions(-) create mode 100644 apps/sim/lib/invitations/pending.test.ts diff --git a/apps/sim/lib/invitations/pending.test.ts b/apps/sim/lib/invitations/pending.test.ts new file mode 100644 index 00000000000..3c707762774 --- /dev/null +++ b/apps/sim/lib/invitations/pending.test.ts @@ -0,0 +1,108 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetInvitationJoinPreview, mockListPendingInvitationsForEmail } = vi.hoisted(() => ({ + mockGetInvitationJoinPreview: vi.fn(), + mockListPendingInvitationsForEmail: vi.fn(), +})) + +vi.mock('@/lib/invitations/core', () => ({ + getInvitationJoinPreview: mockGetInvitationJoinPreview, + listPendingInvitationsForEmail: mockListPendingInvitationsForEmail, +})) + +import { listPendingInvitationsForViewer } from '@/lib/invitations/pending' + +const USER_ID = 'user-1' +const EMAIL = 'invitee@example.com' + +function invitation(id: string) { + return { + id, + kind: 'organization' as const, + email: EMAIL, + 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('listPendingInvitationsForViewer', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + 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 rows = await listPendingInvitationsForViewer(USER_ID, EMAIL) + + expect(rows.map((r) => r.id)).toEqual(['a', 'b', 'c']) + expect(rows.map((r) => r.joinPreview)).toEqual([{ for: 'a' }, { for: 'b' }, { for: 'c' }]) + }) + + /** + * The preview is disclosure-only, so one failing row must degrade to `null` rather than + * hiding the invitation or failing the batch — 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 rows = await listPendingInvitationsForViewer(USER_ID, EMAIL) + + expect(rows).toHaveLength(2) + expect(rows[0].joinPreview).toBeNull() + expect(rows[1].joinPreview).toEqual({ for: 'b' }) + }) + + /** + * This runs inside the sidebar prefetch on every workspace page render, and each preview + * issues several queries — so the fan-out stays bounded rather than holding one pooled + * connection per pending invitation. + */ + it('bounds how many previews run at once', 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 listPendingInvitationsForViewer(USER_ID, EMAIL) + + expect(mockGetInvitationJoinPreview).toHaveBeenCalledTimes(12) + expect(peak).toBeGreaterThan(1) + expect(peak).toBeLessThanOrEqual(4) + }) + + it('serializes dates to the wire shape', async () => { + mockListPendingInvitationsForEmail.mockResolvedValue([invitation('a')]) + mockGetInvitationJoinPreview.mockResolvedValue(null) + + const [row] = await listPendingInvitationsForViewer(USER_ID, EMAIL) + + expect(row.expiresAt).toBe('2026-02-01T00:00:00.000Z') + expect(row.createdAt).toBe('2026-01-01T00:00:00.000Z') + }) +}) diff --git a/apps/sim/lib/invitations/pending.ts b/apps/sim/lib/invitations/pending.ts index 131ecff3ccc..86af76bb63b 100644 --- a/apps/sim/lib/invitations/pending.ts +++ b/apps/sim/lib/invitations/pending.ts @@ -1,9 +1,17 @@ import { createLogger } from '@sim/logger' import type { ViewerInvitation } from '@/lib/api/contracts/invitations' +import { mapWithConcurrency } from '@/lib/core/utils/concurrency' import { getInvitationJoinPreview, listPendingInvitationsForEmail } from '@/lib/invitations/core' const logger = createLogger('PendingInvitations') +/** + * Bounds the join-preview fan-out. A pending-invitation list is a handful of rows, so this + * resolves the common cases in one batch while still capping how many pooled connections a + * single page render can hold. + */ +const INVITATION_PREVIEW_CONCURRENCY = 4 + /** * The invitee-facing pending-invitation list behind the workspace switcher's * Invitations section, assembled once for both the `GET /api/invitations` route @@ -22,26 +30,31 @@ export async function listPendingInvitationsForViewer( * Each row carries what accepting it will actually do, so the client can echo * `disclosedWorkspaceIds` on accept — the consent contract the emailed * `/invite` page honours. A preview failure degrades to `null` rather than - * hiding the invitation. + * hiding the invitation, which is what lets this run under a bounded mapper. * - * Sequential on purpose: each preview issues several queries, and the sidebar - * prefetch calls this on every workspace page load. 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. + * Bounded rather than unbounded: each preview issues up to three sequential + * queries, so `Promise.all` would hold one pooled connection per pending + * invitation for the length of the slowest one. It was previously a serial + * loop for that reason, which is no longer affordable now that the sidebar + * prefetch calls this on every workspace page render rather than only when + * the switcher opens — a serial loop puts every one of those queries on the + * critical path of the first byte. */ - const previews: Array> | null> = [] - for (const inv of invitations) { - try { - previews.push(await getInvitationJoinPreview(userId, 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(userId, inv) + } catch (previewError) { + logger.warn('Failed to compute join preview for pending invitation', { + invitationId: inv.id, + error: previewError, + }) + return null + } } - } + ) return invitations.map( (inv, index) =>