Skip to content

Commit 0fa05c8

Browse files
committed
perf(prefetch): read the data layer instead of calling our own API over the wire
Four server-render prefetches went out over HTTP to our own routes. With INTERNAL_API_BASE_URL unset in prod, getInternalApiBaseUrl() falls back to the public base URL, so each was RSC -> public HTTPS -> load balancer -> back into the app, awaited inside the render with a second round of auth. - /home fetched the workflow folder list that the workspace layout had already fetched, under the identical query key. Since getQueryClient() builds a new client per call on the server, the two never deduped: same data, twice a request, once directly and once over the wire. Dropped; the layout's entry already hydrates it. - /home cached raw route JSON under workspaceFilesKeys.list, while files/prefetch.ts seeds that same key from listWorkspaceFilesWithShares. The contract declares the date fields z.coerce.date(), so consumers hold Dates — a file record's type depended on which page the viewer landed on. Now reads the same function files/prefetch.ts does. - tables and knowledge folder reads now call listFoldersForWorkspace, matching the sidebar prefetch. These reads carry no authorization of their own, so each surface proves the viewer through getWorkspaceHostContextForViewer first and caches nothing when it fails, leaving the client fetch to reach the route for the real 403. Both it and getSession are cache()d and already resolved by the layout, so the proof costs no extra queries. Left on the wire, deliberately: the tables and knowledge lists, whose cached shape is the serialized wire shape, and pinned items and members, which have no exported data-layer function.
1 parent ec70c4d commit 0fa05c8

7 files changed

Lines changed: 201 additions & 90 deletions

File tree

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,18 @@ export default async function HomePage({ params }: { params: Promise<{ workspace
2424
}
2525

2626
const queryClient = getQueryClient()
27-
const listsPrefetch = prefetchHomeLists(queryClient, workspaceId)
2827

28+
/**
29+
* `getSession` is `cache`d and the layout has already resolved it for this
30+
* request, so awaiting it before the prefetch costs nothing and gives the
31+
* prefetch the viewer it needs to authorize its own read.
32+
*/
2933
const session = await getSession()
3034
const userId = session?.user?.id
35+
const listsPrefetch = userId
36+
? prefetchHomeLists(queryClient, workspaceId, userId)
37+
: Promise.resolve()
38+
3139
const tableViewsEnabled = await resolveTableViewsEnabled(workspaceId, userId)
3240
await listsPrefetch
3341

Lines changed: 31 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,45 @@
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'
5-
import { FOLDER_LIST_STALE_TIME, folderKeys, mapFolder } from '@/hooks/queries/utils/folder-keys'
2+
import { listWorkspaceFilesWithShares } from '@/lib/workspace-files/queries'
3+
import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
64
import {
75
WORKSPACE_FILES_LIST_STALE_TIME,
86
workspaceFilesKeys,
97
} from '@/hooks/queries/workspace-files'
108

119
/**
12-
* Prefetches the home page's secondary lists — folders and workspace files —
13-
* under the same query keys their client hooks (`useFolders`,
14-
* `useWorkspaceFiles`) use, so the home view paints populated on first render.
10+
* Prefetches the workspace files the home view lists, under the same query key
11+
* its client hook (`useWorkspaceFiles`) uses, so the view paints populated on
12+
* first render.
1513
*
16-
* The workflow list (`workflowKeys.list(ws, 'active')`) is already hydrated by
17-
* the workspace sidebar prefetch and is intentionally not repeated here.
14+
* Reads the data layer rather than the route, which drops a server-to-server
15+
* request and its duplicate auth. It also fixes the shape this key was seeded
16+
* with: `listWorkspaceFilesContract` declares the date fields as
17+
* `z.coerce.date()`, so every consumer of `workspaceFilesKeys.list` holds
18+
* `Date`s, and `files/prefetch.ts` already seeds them that way from this same
19+
* function. Caching the raw route JSON here put ISO strings under that key
20+
* instead, so a file record's type depended on which page the viewer landed on.
1821
*
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}.
22+
* The read carries no authorization of its own, so the viewer is proved first.
23+
* `getWorkspaceHostContextForViewer` is `cache`d and the layout has already
24+
* resolved it for this request, so this costs no additional queries; a viewer
25+
* without access caches nothing and the client fetch reaches the route for the
26+
* real 403.
27+
*
28+
* Folders (`folderKeys.list(ws, 'active', 'workflow')`) and the workflow list
29+
* are both already hydrated by the workspace sidebar prefetch and are
30+
* intentionally not repeated here.
2331
*/
2432
export async function prefetchHomeLists(
2533
queryClient: QueryClient,
26-
workspaceId: string
34+
workspaceId: string,
35+
userId: string
2736
): Promise<void> {
28-
await Promise.all([
29-
queryClient.prefetchQuery({
30-
queryKey: folderKeys.list(workspaceId, 'active', 'workflow'),
31-
queryFn: async () => {
32-
const { folders } = await prefetchInternalJson<{ folders?: FolderApi[] }>(
33-
`/api/folders?workspaceId=${workspaceId}&scope=active&resourceType=workflow`
34-
)
35-
return (folders ?? []).map(mapFolder)
36-
},
37-
staleTime: FOLDER_LIST_STALE_TIME,
38-
}),
39-
queryClient.prefetchQuery({
40-
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-
},
47-
staleTime: WORKSPACE_FILES_LIST_STALE_TIME,
48-
}),
49-
])
37+
const hostContext = await getWorkspaceHostContextForViewer(workspaceId, userId)
38+
if (!hostContext) return
39+
40+
await queryClient.prefetchQuery({
41+
queryKey: workspaceFilesKeys.list(workspaceId, 'active'),
42+
queryFn: () => listWorkspaceFilesWithShares(workspaceId, 'active'),
43+
staleTime: WORKSPACE_FILES_LIST_STALE_TIME,
44+
})
5045
}

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

Lines changed: 8 additions & 1 deletion
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 { FOLDERED_RESOURCE_HEADERS } from '@/app/workspace/[workspaceId]/components/folders/foldered-resources'
67
import { Knowledge } from '@/app/workspace/[workspaceId]/knowledge/knowledge'
@@ -27,7 +28,13 @@ export default async function KnowledgePage({
2728
const { workspaceId } = await params
2829

2930
const queryClient = getQueryClient()
30-
await prefetchKnowledgeBases(queryClient, workspaceId)
31+
/**
32+
* `getSession` is `cache`d and the layout has already resolved it for this
33+
* request, so this costs nothing and gives the prefetch the viewer its
34+
* data-layer reads need to authorize against.
35+
*/
36+
const session = await getSession()
37+
await prefetchKnowledgeBases(queryClient, workspaceId, session?.user?.id ?? '')
3138

3239
return (
3340
<HydrationBoundary state={dehydrate(queryClient)}>
Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { QueryClient } from '@tanstack/react-query'
2-
import type { FolderApi } from '@/lib/api/contracts/folders'
32
import type { KnowledgeBaseData } from '@/lib/api/contracts/knowledge'
3+
import { listFoldersForWorkspace } from '@/lib/folders/queries'
4+
import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
45
import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch'
56
import { prefetchResourceListChrome } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome'
67
import { FOLDER_LIST_STALE_TIME, folderKeys, mapFolder } from '@/hooks/queries/utils/folder-keys'
@@ -16,14 +17,23 @@ import { KNOWLEDGE_BASE_LIST_STALE_TIME, knowledgeKeys } from '@/hooks/queries/u
1617
* beside, so prefetching one without the other still flashes an ungrouped list — and a
1718
* `?folderId=` deep link renders an empty breadcrumb until the folders arrive.
1819
*
19-
* The list carries `Date` fields, so it goes through the `/api/knowledge` route and caches the
20-
* serialized wire shape — see {@link prefetchInternalJson}. Folders are mapped with the same
21-
* `mapFolder` the hook applies, so the hydrated entry matches a client fetch exactly.
20+
* Folders read the data layer and are mapped with the same `mapFolder` the hook applies,
21+
* matching the workspace sidebar prefetch. That read carries no authorization of its own, so
22+
* the viewer is proved first; `getWorkspaceHostContextForViewer` is `cache`d and the layout has
23+
* already resolved it for this request, so it costs no additional queries.
24+
*
25+
* The bases list still goes through the `/api/knowledge` route — see
26+
* {@link prefetchInternalJson}. It is served by an application use case that authorizes against
27+
* a `Principal`, so converting it means constructing that principal here rather than reading a
28+
* manager directly.
2229
*/
2330
export async function prefetchKnowledgeBases(
2431
queryClient: QueryClient,
25-
workspaceId: string
32+
workspaceId: string,
33+
userId: string
2634
): Promise<void> {
35+
const hostContext = await getWorkspaceHostContextForViewer(workspaceId, userId)
36+
2737
await Promise.all([
2838
queryClient.prefetchQuery({
2939
queryKey: knowledgeKeys.list(workspaceId, 'active'),
@@ -35,16 +45,18 @@ export async function prefetchKnowledgeBases(
3545
},
3646
staleTime: KNOWLEDGE_BASE_LIST_STALE_TIME,
3747
}),
38-
queryClient.prefetchQuery({
39-
queryKey: folderKeys.list(workspaceId, 'active', 'knowledge_base'),
40-
queryFn: async () => {
41-
const { folders } = await prefetchInternalJson<{ folders?: FolderApi[] }>(
42-
`/api/folders?workspaceId=${workspaceId}&scope=active&resourceType=knowledge_base`
43-
)
44-
return (folders ?? []).map(mapFolder)
45-
},
46-
staleTime: FOLDER_LIST_STALE_TIME,
47-
}),
48+
...(hostContext
49+
? [
50+
queryClient.prefetchQuery({
51+
queryKey: folderKeys.list(workspaceId, 'active', 'knowledge_base'),
52+
queryFn: async () => {
53+
const rows = await listFoldersForWorkspace(workspaceId, 'active', 'knowledge_base')
54+
return rows.map(mapFolder)
55+
},
56+
staleTime: FOLDER_LIST_STALE_TIME,
57+
}),
58+
]
59+
: []),
4860
prefetchResourceListChrome(queryClient, workspaceId, 'knowledge_base'),
4961
])
5062
}

apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts

Lines changed: 87 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
66

77
const {
88
mockGetWorkspaceHostContextForViewer,
9+
mockListFoldersForWorkspace,
910
mockListWorkspaceFileFolders,
1011
mockListWorkspaceFilesWithShares,
1112
mockPrefetchInternalJson,
1213
} = vi.hoisted(() => ({
1314
mockGetWorkspaceHostContextForViewer: vi.fn(),
15+
mockListFoldersForWorkspace: vi.fn(),
1416
mockListWorkspaceFileFolders: vi.fn(),
1517
mockListWorkspaceFilesWithShares: vi.fn(),
1618
mockPrefetchInternalJson: vi.fn(),
@@ -19,6 +21,9 @@ const {
1921
vi.mock('@/lib/workspaces/host-context', () => ({
2022
getWorkspaceHostContextForViewer: mockGetWorkspaceHostContextForViewer,
2123
}))
24+
vi.mock('@/lib/folders/queries', () => ({
25+
listFoldersForWorkspace: mockListFoldersForWorkspace,
26+
}))
2227
vi.mock('@/lib/workspace-files/queries', () => ({
2328
listWorkspaceFilesWithShares: mockListWorkspaceFilesWithShares,
2429
}))
@@ -57,17 +62,79 @@ describe('workspace list prefetches', () => {
5762
beforeEach(() => {
5863
vi.clearAllMocks()
5964
mockGetWorkspaceHostContextForViewer.mockResolvedValue({ viewer: { permission: 'admin' } })
65+
mockListFoldersForWorkspace.mockResolvedValue([])
6066
mockListWorkspaceFilesWithShares.mockResolvedValue([])
6167
mockListWorkspaceFileFolders.mockResolvedValue([])
6268
})
6369

70+
describe.each([
71+
{
72+
name: 'prefetchKnowledgeBases',
73+
run: (client: QueryClient) => prefetchKnowledgeBases(client, WORKSPACE_ID, USER_ID),
74+
resourceType: 'knowledge_base' as const,
75+
},
76+
{
77+
name: 'prefetchTables',
78+
run: (client: QueryClient) => prefetchTables(client, WORKSPACE_ID, USER_ID),
79+
resourceType: 'table' as const,
80+
},
81+
])('$name folder reads', ({ run, resourceType }) => {
82+
it('reads folders from the data layer rather than over the wire', async () => {
83+
const folderRow = {
84+
id: 'fld-1',
85+
name: 'Folder',
86+
userId: 'u-1',
87+
workspaceId: WORKSPACE_ID,
88+
parentId: null,
89+
resourceType,
90+
locked: false,
91+
sortOrder: 0,
92+
createdAt: '2026-01-01T00:00:00.000Z',
93+
updatedAt: '2026-01-02T00:00:00.000Z',
94+
deletedAt: null,
95+
}
96+
mockPrefetchInternalJson.mockResolvedValue({ data: { tables: [] } })
97+
mockListFoldersForWorkspace.mockResolvedValue([folderRow])
98+
const client = makeClient()
99+
100+
await run(client)
101+
102+
expect(mockListFoldersForWorkspace).toHaveBeenCalledWith(WORKSPACE_ID, 'active', resourceType)
103+
expect(mockPrefetchInternalJson).not.toHaveBeenCalledWith(
104+
expect.stringContaining('/api/folders')
105+
)
106+
const cached = client.getQueryData(
107+
folderKeys.list(WORKSPACE_ID, 'active', resourceType)
108+
) as Array<{
109+
resourceType: string
110+
createdAt: Date
111+
}>
112+
expect(cached).toHaveLength(1)
113+
expect(cached[0].resourceType).toBe(resourceType)
114+
expect(cached[0].createdAt).toBeInstanceOf(Date)
115+
})
116+
117+
it('skips the folder read when the viewer cannot be proved', async () => {
118+
mockPrefetchInternalJson.mockResolvedValue({ data: { tables: [] } })
119+
mockGetWorkspaceHostContextForViewer.mockResolvedValue(null)
120+
const client = makeClient()
121+
122+
await run(client)
123+
124+
expect(mockListFoldersForWorkspace).not.toHaveBeenCalled()
125+
expect(
126+
client.getQueryData(folderKeys.list(WORKSPACE_ID, 'active', resourceType))
127+
).toBeUndefined()
128+
})
129+
})
130+
64131
describe('prefetchKnowledgeBases', () => {
65132
it('primes the exact key useKnowledgeBasesQuery reads and unwraps data', async () => {
66133
const bases = [{ id: 'kb-1' }]
67134
mockPrefetchInternalJson.mockResolvedValue({ data: bases })
68135
const client = makeClient()
69136

70-
await prefetchKnowledgeBases(client, WORKSPACE_ID)
137+
await prefetchKnowledgeBases(client, WORKSPACE_ID, USER_ID)
71138

72139
expect(mockPrefetchInternalJson).toHaveBeenCalledWith(
73140
`/api/knowledge?workspaceId=${WORKSPACE_ID}&scope=active`
@@ -82,7 +149,7 @@ describe('workspace list prefetches', () => {
82149
mockPrefetchInternalJson.mockResolvedValue({ data: { tables } })
83150
const client = makeClient()
84151

85-
await prefetchTables(client, WORKSPACE_ID)
152+
await prefetchTables(client, WORKSPACE_ID, USER_ID)
86153

87154
expect(mockPrefetchInternalJson).toHaveBeenCalledWith(
88155
`/api/table?workspaceId=${WORKSPACE_ID}&scope=active`
@@ -138,12 +205,12 @@ describe('workspace list prefetches', () => {
138205
},
139206
{
140207
name: 'tables',
141-
run: (client: QueryClient) => prefetchTables(client, WORKSPACE_ID),
208+
run: (client: QueryClient) => prefetchTables(client, WORKSPACE_ID, USER_ID),
142209
resourceType: 'table' as const,
143210
},
144211
{
145212
name: 'knowledge',
146-
run: (client: QueryClient) => prefetchKnowledgeBases(client, WORKSPACE_ID),
213+
run: (client: QueryClient) => prefetchKnowledgeBases(client, WORKSPACE_ID, USER_ID),
147214
resourceType: 'knowledge_base' as const,
148215
},
149216
]
@@ -203,20 +270,20 @@ describe('workspace list prefetches', () => {
203270
)
204271
const client = makeClient()
205272

206-
await prefetchHomeLists(client, WORKSPACE_ID)
273+
mockListWorkspaceFilesWithShares.mockResolvedValue(files)
207274

208-
expect(mockPrefetchInternalJson).toHaveBeenCalledWith(
209-
`/api/folders?workspaceId=${WORKSPACE_ID}&scope=active&resourceType=workflow`
210-
)
211-
const cachedFolders = client.getQueryData(folderKeys.list(WORKSPACE_ID, 'active')) as Array<{
212-
id: string
213-
resourceType: string
214-
createdAt: Date
215-
}>
216-
expect(cachedFolders).toHaveLength(1)
217-
expect(cachedFolders[0].resourceType).toBe('workflow')
218-
// The wire shape carries ISO strings; the client shape carries Dates.
219-
expect(cachedFolders[0].createdAt).toBeInstanceOf(Date)
275+
await prefetchHomeLists(client, WORKSPACE_ID, USER_ID)
276+
277+
/**
278+
* Folders are hydrated by the workspace sidebar prefetch under this same
279+
* key, so repeating them here would be a second read of data the layout
280+
* already has.
281+
*/
282+
expect(mockPrefetchInternalJson).not.toHaveBeenCalled()
283+
expect(mockListFoldersForWorkspace).not.toHaveBeenCalled()
284+
expect(client.getQueryData(folderKeys.list(WORKSPACE_ID, 'active'))).toBeUndefined()
285+
286+
expect(mockListWorkspaceFilesWithShares).toHaveBeenCalledWith(WORKSPACE_ID, 'active')
220287
expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual(files)
221288
})
222289
})
@@ -225,17 +292,17 @@ describe('workspace list prefetches', () => {
225292
it.each([
226293
[
227294
'prefetchKnowledgeBases',
228-
(client: QueryClient) => prefetchKnowledgeBases(client, WORKSPACE_ID),
295+
(client: QueryClient) => prefetchKnowledgeBases(client, WORKSPACE_ID, USER_ID),
229296
knowledgeKeys.list(WORKSPACE_ID, 'active'),
230297
],
231298
[
232299
'prefetchTables',
233-
(client: QueryClient) => prefetchTables(client, WORKSPACE_ID),
300+
(client: QueryClient) => prefetchTables(client, WORKSPACE_ID, USER_ID),
234301
tableKeys.list(WORKSPACE_ID, 'active'),
235302
],
236303
[
237304
'prefetchHomeLists',
238-
(client: QueryClient) => prefetchHomeLists(client, WORKSPACE_ID),
305+
(client: QueryClient) => prefetchHomeLists(client, WORKSPACE_ID, USER_ID),
239306
folderKeys.list(WORKSPACE_ID, 'active'),
240307
],
241308
[

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

Lines changed: 8 additions & 1 deletion
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 TablesLoading from '@/app/workspace/[workspaceId]/tables/loading'
67
import { prefetchTables } from '@/app/workspace/[workspaceId]/tables/prefetch'
@@ -20,7 +21,13 @@ export default async function TablesPage({ params }: { params: Promise<{ workspa
2021
const { workspaceId } = await params
2122

2223
const queryClient = getQueryClient()
23-
await prefetchTables(queryClient, workspaceId)
24+
/**
25+
* `getSession` is `cache`d and the layout has already resolved it for this
26+
* request, so this costs nothing and gives the prefetch the viewer its
27+
* data-layer reads need to authorize against.
28+
*/
29+
const session = await getSession()
30+
await prefetchTables(queryClient, workspaceId, session?.user?.id ?? '')
2431

2532
return (
2633
<HydrationBoundary state={dehydrate(queryClient)}>

0 commit comments

Comments
 (0)