Skip to content

Commit c8cc8ad

Browse files
committed
fix(prefetch): drop the internal HTTP hop from every resource list prefetch
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.
1 parent cb8338c commit c8cc8ad

28 files changed

Lines changed: 525 additions & 411 deletions

File tree

apps/sim/app/api/invitations/route.ts

Lines changed: 5 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
import { createLogger } from '@sim/logger'
22
import { NextResponse } from 'next/server'
3-
import type { MyInvitation } from '@/lib/api/contracts/invitations'
43
import { getSession } from '@/lib/auth'
54
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
6-
import { getInvitationJoinPreview, listPendingInvitationsForEmail } from '@/lib/invitations/core'
5+
import { listPendingInvitationsForViewer } from '@/lib/invitations/pending'
76

8-
const logger = createLogger('MyInvitationsAPI')
7+
const logger = createLogger('InvitationsAPI')
98

109
/**
1110
* Pending invitations addressed to the session's email — the invitee-facing
12-
* list behind the workspace switcher's Invitations section. Acceptance is
13-
* session-bound (email match), so rows deliberately exclude the token.
11+
* list behind the workspace switcher's Invitations section.
1412
*/
1513
export const GET = withRouteHandler(async () => {
1614
const session = await getSession()
@@ -20,59 +18,8 @@ export const GET = withRouteHandler(async () => {
2018
}
2119

2220
try {
23-
const invitations = await listPendingInvitationsForEmail(session.user.email)
24-
25-
/**
26-
* Each row carries what accepting it will actually do, so the in-app list
27-
* can disclose the workspace migration and echo `disclosedWorkspaceIds` on
28-
* accept — the same consent contract the emailed `/invite` page honours.
29-
* Disclosure-only, so a preview failure degrades to `null` (the client
30-
* shows a generic notice) rather than hiding the invitation.
31-
*
32-
* Sequential on purpose: each preview issues several queries, and this
33-
* endpoint is hit whenever the workspace switcher opens. Fanning them out
34-
* with `Promise.all` would hold one pooled connection per pending
35-
* invitation for the length of the slowest one. The list is a handful of
36-
* rows, so the added latency is not worth the pool pressure.
37-
*/
38-
const previews: Array<Awaited<ReturnType<typeof getInvitationJoinPreview>> | null> = []
39-
for (const inv of invitations) {
40-
try {
41-
previews.push(await getInvitationJoinPreview(session.user.id, inv))
42-
} catch (previewError) {
43-
logger.warn('Failed to compute join preview for pending invitation', {
44-
invitationId: inv.id,
45-
error: previewError,
46-
})
47-
previews.push(null)
48-
}
49-
}
50-
51-
return NextResponse.json({
52-
invitations: invitations.map(
53-
(inv, index) =>
54-
({
55-
id: inv.id,
56-
kind: inv.kind,
57-
email: inv.email,
58-
organizationId: inv.organizationId,
59-
organizationName: inv.organizationName,
60-
membershipIntent: inv.membershipIntent,
61-
role: inv.role,
62-
status: inv.status,
63-
expiresAt: inv.expiresAt.toISOString(),
64-
createdAt: inv.createdAt.toISOString(),
65-
inviterName: inv.inviterName,
66-
inviterEmail: inv.inviterEmail,
67-
grants: inv.grants.map((grant) => ({
68-
workspaceId: grant.workspaceId,
69-
workspaceName: grant.workspaceName,
70-
permission: grant.permission,
71-
})),
72-
joinPreview: previews[index],
73-
}) satisfies MyInvitation
74-
),
75-
})
21+
const invitations = await listPendingInvitationsForViewer(session.user.id, session.user.email)
22+
return NextResponse.json({ invitations })
7623
} catch (error) {
7724
logger.error('Failed to list pending invitations', { error })
7825
return NextResponse.json({ error: 'Failed to list invitations' }, { status: 500 })

apps/sim/app/api/knowledge/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ import { PlatformEvents } from '@/lib/core/telemetry'
1111
import { generateRequestId } from '@/lib/core/utils/request'
1212
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1313
import { EMBEDDING_DIMENSIONS, getConfiguredEmbeddingModel } from '@/lib/knowledge/embeddings'
14+
import { listKnowledgeBasesForViewer } from '@/lib/knowledge/queries'
1415
import {
1516
createKnowledgeBase,
16-
getKnowledgeBases,
1717
KnowledgeBaseConflictError,
1818
KnowledgeBaseFolderError,
1919
KnowledgeBasePermissionError,
@@ -46,7 +46,7 @@ export const GET = withRouteHandler(async (req: NextRequest) => {
4646
}
4747
const { workspaceId, scope } = query.data
4848

49-
const knowledgeBasesWithCounts = await getKnowledgeBases(
49+
const knowledgeBasesWithCounts = await listKnowledgeBasesForViewer(
5050
session.user.id,
5151
workspaceId,
5252
scope as KnowledgeBaseScope

apps/sim/app/api/pinned-items/route.ts

Lines changed: 3 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2,40 +2,21 @@ import { db, pinnedItem } from '@sim/db'
22
import { createLogger } from '@sim/logger'
33
import { getPostgresErrorCode } from '@sim/utils/errors'
44
import { generateId } from '@sim/utils/id'
5-
import { and, eq, ne } from 'drizzle-orm'
65
import { type NextRequest, NextResponse } from 'next/server'
76
import {
87
createPinnedItemContract,
98
listPinnedItemsContract,
109
type PinnedItemApi,
11-
pinnedResourceTypeSchema,
1210
} from '@/lib/api/contracts'
1311
import { parseRequest } from '@/lib/api/server'
1412
import { getSession } from '@/lib/auth'
1513
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
16-
import { filterToActiveResources, pinnableResourceExists } from '@/lib/pinned-items/resources'
14+
import { listPinnedItemsForViewer } from '@/lib/pinned-items/queries'
15+
import { pinnableResourceExists } from '@/lib/pinned-items/resources'
1716
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1817

1918
const logger = createLogger('PinnedItemsAPI')
2019

21-
/**
22-
* Narrows a stored row to the wire shape, dropping any row whose `resourceType` this build does
23-
* not recognise.
24-
*
25-
* `pinned_item.resource_type` is plain `text` — deliberately, so the set of pinnable kinds can
26-
* grow — while the contract is a closed enum. During a rolling deploy an older pod can therefore
27-
* read a pin a newer one wrote. Returning it would fail response validation and take the WHOLE
28-
* list down rather than the single row, so the unknown kind is skipped instead.
29-
*
30-
* `filterToActiveResources` already drops these as a side effect of not having a table to look
31-
* them up in; this makes the guarantee explicit and compiler-checked at the wire boundary.
32-
*/
33-
function toPinnedItemApi(row: typeof pinnedItem.$inferSelect): PinnedItemApi | null {
34-
const resourceType = pinnedResourceTypeSchema.safeParse(row.resourceType)
35-
if (!resourceType.success) return null
36-
return { ...row, resourceType: resourceType.data, pinnedAt: row.pinnedAt.toISOString() }
37-
}
38-
3920
/** Lists the session user's pinned items in a workspace, optionally filtered to one `resourceType`. */
4021
export const GET = withRouteHandler(async (request: NextRequest) => {
4122
const session = await getSession()
@@ -52,30 +33,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
5233
return NextResponse.json({ error: 'Access denied to this workspace' }, { status: 403 })
5334
}
5435

55-
const rows = await db
56-
.select()
57-
.from(pinnedItem)
58-
.where(
59-
and(
60-
eq(pinnedItem.userId, session.user.id),
61-
eq(pinnedItem.workspaceId, workspaceId),
62-
/**
63-
* A `workspace` pin stores `workspaceId === resourceId`, so it would otherwise
64-
* appear in this workspace's unscoped listing as a resource *inside* itself.
65-
* It is read from the workspace-list payload instead, so it is excluded here
66-
* rather than left for a future unscoped caller to mistake for a real resource.
67-
*/
68-
resourceType
69-
? eq(pinnedItem.resourceType, resourceType)
70-
: ne(pinnedItem.resourceType, 'workspace')
71-
)
72-
)
73-
74-
const activeRows = await filterToActiveResources(rows, workspaceId)
75-
76-
const pinnedItems = activeRows
77-
.map(toPinnedItemApi)
78-
.filter((item): item is PinnedItemApi => item !== null)
36+
const pinnedItems = await listPinnedItemsForViewer(session.user.id, workspaceId, resourceType)
7937

8038
return NextResponse.json({ pinnedItems })
8139
})

apps/sim/app/api/workspaces/[id]/files/route.ts

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,10 @@ import {
1616
} from '@/lib/core/utils/stream-limits'
1717
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1818
import { captureServerEvent } from '@/lib/posthog/server'
19-
import { getWorkspaceShares } from '@/lib/public-shares/share-manager'
20-
import {
21-
FileConflictError,
22-
listWorkspaceFiles,
23-
uploadWorkspaceFile,
24-
} from '@/lib/uploads/contexts/workspace'
19+
import { FileConflictError, uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace'
2520
import { EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance'
2621
import { MAX_WORKSPACE_FORMDATA_FILE_SIZE } from '@/lib/uploads/shared/types'
22+
import { listWorkspaceFilesWithShares } from '@/lib/workspace-files/queries'
2723
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
2824
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
2925

@@ -73,15 +69,11 @@ export const GET = withRouteHandler(
7369
}
7470
const { scope } = queryResult.data
7571

76-
const files = await listWorkspaceFiles(workspaceId, { scope })
77-
78-
const shares = await getWorkspaceShares('file', workspaceId)
79-
const filesWithShares = files.map((file) => ({
80-
...file,
81-
share: shares.get(file.id) ?? null,
82-
}))
72+
const filesWithShares = await listWorkspaceFilesWithShares(workspaceId, scope)
8373

84-
logger.info(`[${requestId}] Listed ${files.length} files for workspace ${workspaceId}`)
74+
logger.info(
75+
`[${requestId}] Listed ${filesWithShares.length} files for workspace ${workspaceId}`
76+
)
8577

8678
return NextResponse.json({
8779
success: true,

apps/sim/app/workspace/[workspaceId]/files/page.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Suspense } from 'react'
22
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
33
import type { Metadata } from 'next'
4+
import { getSession } from '@/lib/auth'
45
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
56
import { prefetchFilesBrowser } from '@/app/workspace/[workspaceId]/files/prefetch'
67
import { Files } from './files'
@@ -19,10 +20,12 @@ export const metadata: Metadata = {
1920
* `loading.tsx` covers the navigation/chunk-load transition the same way.
2021
*/
2122
export default async function FilesPage({ params }: { params: Promise<{ workspaceId: string }> }) {
22-
const { workspaceId } = await params
23+
const [{ workspaceId }, session] = await Promise.all([params, getSession()])
2324

2425
const queryClient = getQueryClient()
25-
await prefetchFilesBrowser(queryClient, workspaceId)
26+
if (session?.user?.id) {
27+
await prefetchFilesBrowser(queryClient, workspaceId, session.user.id)
28+
}
2629

2730
return (
2831
<HydrationBoundary state={dehydrate(queryClient)}>
Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { QueryClient } from '@tanstack/react-query'
2-
import type { WorkspaceFileFolderApi } from '@/lib/api/contracts/workspace-file-folders'
3-
import type { ListWorkspaceFilesResponse } from '@/lib/api/contracts/workspace-files'
4-
import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch'
2+
import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace'
3+
import { listWorkspaceFilesWithShares } from '@/lib/workspace-files/queries'
4+
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
55
import { prefetchResourceListChrome } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome'
66
import {
77
WORKSPACE_FILE_FOLDERS_STALE_TIME,
@@ -17,37 +17,36 @@ import {
1717
* first frame: workspace files, file folders, and (via {@link prefetchResourceListChrome})
1818
* the pinned ids that drive row order plus the members behind the Owner column —
1919
* under the same query keys their client hooks (`useWorkspaceFiles`,
20-
* `useWorkspaceFileFolders`) use (scope `active`), so the browser paints
21-
* populated on first render.
20+
* `useWorkspaceFileFolders`) use (scope `active`), so the browser paints populated
21+
* on first render.
2222
*
23-
* Both payloads carry `Date` fields, so they go through their routes and cache
24-
* the serialized wire shape — see {@link prefetchInternalJson}.
23+
* Calls the data layer directly — the same functions the API routes use — matching
24+
* `prefetchWorkspaceSidebar`. A rejection here is swallowed by `prefetchQuery` and the
25+
* errored entry dropped by `shouldDehydrateQuery`, so one list failing must not take
26+
* its siblings down with it.
27+
*
28+
* Membership is verified once rather than per-list. Without access nothing is cached,
29+
* so the client fetch reaches the route and gets the real 403.
2530
*/
2631
export async function prefetchFilesBrowser(
2732
queryClient: QueryClient,
28-
workspaceId: string
33+
workspaceId: string,
34+
userId: string
2935
): Promise<void> {
36+
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
37+
if (!permission) return
38+
3039
await Promise.all([
3140
queryClient.prefetchQuery({
3241
queryKey: workspaceFilesKeys.list(workspaceId, 'active'),
33-
queryFn: async () => {
34-
const data = await prefetchInternalJson<ListWorkspaceFilesResponse>(
35-
`/api/workspaces/${workspaceId}/files?scope=active`
36-
)
37-
return data.success ? data.files : []
38-
},
42+
queryFn: () => listWorkspaceFilesWithShares(workspaceId, 'active'),
3943
staleTime: WORKSPACE_FILES_LIST_STALE_TIME,
4044
}),
4145
queryClient.prefetchQuery({
4246
queryKey: workspaceFileFolderKeys.list(workspaceId, 'active'),
43-
queryFn: async () => {
44-
const data = await prefetchInternalJson<{ folders?: WorkspaceFileFolderApi[] }>(
45-
`/api/workspaces/${workspaceId}/files/folders?scope=active`
46-
)
47-
return data.folders ?? []
48-
},
47+
queryFn: () => listWorkspaceFileFolders(workspaceId, { scope: 'active' }),
4948
staleTime: WORKSPACE_FILE_FOLDERS_STALE_TIME,
5049
}),
51-
prefetchResourceListChrome(queryClient, workspaceId, 'file'),
50+
prefetchResourceListChrome(queryClient, workspaceId, userId, 'file'),
5251
])
5352
}

apps/sim/app/workspace/[workspaceId]/home/page.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,13 @@ export default async function HomePage({ params }: { params: Promise<{ workspace
2424
}
2525

2626
const queryClient = getQueryClient()
27-
const listsPrefetch = prefetchHomeLists(queryClient, workspaceId)
28-
2927
const session = await getSession()
3028
const userId = session?.user?.id
31-
const tableViewsEnabled = await resolveTableViewsEnabled(workspaceId, userId)
32-
await listsPrefetch
29+
30+
const [tableViewsEnabled] = await Promise.all([
31+
resolveTableViewsEnabled(workspaceId, userId),
32+
userId ? prefetchHomeLists(queryClient, workspaceId, userId) : Promise.resolve(),
33+
])
3334

3435
return (
3536
<HydrationBoundary state={dehydrate(queryClient)}>

apps/sim/app/workspace/[workspaceId]/home/prefetch.ts

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { QueryClient } from '@tanstack/react-query'
2-
import type { FolderApi } from '@/lib/api/contracts'
3-
import type { ListWorkspaceFilesResponse } from '@/lib/api/contracts/workspace-files'
4-
import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch'
2+
import { listFoldersForWorkspace } from '@/lib/folders/queries'
3+
import { listWorkspaceFilesWithShares } from '@/lib/workspace-files/queries'
4+
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
55
import { FOLDER_LIST_STALE_TIME, folderKeys, mapFolder } from '@/hooks/queries/utils/folder-keys'
66
import {
77
WORKSPACE_FILES_LIST_STALE_TIME,
@@ -16,34 +16,30 @@ import {
1616
* The workflow list (`workflowKeys.list(ws, 'active')`) is already hydrated by
1717
* the workspace sidebar prefetch and is intentionally not repeated here.
1818
*
19-
* Folders are fetched through the route and mapped with the same `mapFolder`
20-
* the hook applies, matching its cached shape (string dates → `Date`). Files
21-
* carry `Date` fields, so they go through the route and cache the serialized
22-
* wire shape — see {@link prefetchInternalJson}.
19+
* Folders are mapped with the same `mapFolder` the hook applies, and files go through the
20+
* same `listWorkspaceFilesWithShares` the Files browser and the route use, so the hydrated
21+
* entry matches a client fetch exactly.
2322
*/
2423
export async function prefetchHomeLists(
2524
queryClient: QueryClient,
26-
workspaceId: string
25+
workspaceId: string,
26+
userId: string
2727
): Promise<void> {
28+
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
29+
if (!permission) return
30+
2831
await Promise.all([
2932
queryClient.prefetchQuery({
3033
queryKey: folderKeys.list(workspaceId, 'active', 'workflow'),
3134
queryFn: async () => {
32-
const { folders } = await prefetchInternalJson<{ folders?: FolderApi[] }>(
33-
`/api/folders?workspaceId=${workspaceId}&scope=active&resourceType=workflow`
34-
)
35-
return (folders ?? []).map(mapFolder)
35+
const folders = await listFoldersForWorkspace(workspaceId, 'active', 'workflow')
36+
return folders.map(mapFolder)
3637
},
3738
staleTime: FOLDER_LIST_STALE_TIME,
3839
}),
3940
queryClient.prefetchQuery({
4041
queryKey: workspaceFilesKeys.list(workspaceId, 'active'),
41-
queryFn: async () => {
42-
const data = await prefetchInternalJson<ListWorkspaceFilesResponse>(
43-
`/api/workspaces/${workspaceId}/files?scope=active`
44-
)
45-
return data.success ? data.files : []
46-
},
42+
queryFn: () => listWorkspaceFilesWithShares(workspaceId, 'active'),
4743
staleTime: WORKSPACE_FILES_LIST_STALE_TIME,
4844
}),
4945
])

0 commit comments

Comments
 (0)