Skip to content

Commit 73fbd49

Browse files
committed
fix(prefetch): keep the tables list on its route and cut the executor edge
Reading listTables from a page prefetch put the executable tool registry into the Tables page server graph — ~4,700 modules, which check:tool-registry-boundary rejects. lib/table/service reaches workflow-columns by several independent paths (directly, and through jobs/service and rows/service), so severing one edge is not enough; untangling that belongs in its own change. - The tables list goes back through GET /api/table, with the reason recorded so the next person does not repeat the attempt. Folders and chrome on that page stay on the data layer. - stripGroupDeps moves to its own leaf module. It is a pure projection over a WorkflowGroup, but living beside the group runtime meant every importer of lib/table/service paid for the executor to get it. Net effect on the Tables page graph: 2,186 modules to 1,742.
1 parent 04524bb commit 73fbd49

6 files changed

Lines changed: 89 additions & 119 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { headers } from 'next/headers'
2+
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
3+
4+
/**
5+
* Server-side GET against an internal `/api` route, forwarding the incoming
6+
* request's cookie so the route authenticates as the current user.
7+
*
8+
* The legacy path. Reading the data layer and shaping the result through the
9+
* route's response contract — as `files/prefetch.ts` does — is canonical: it
10+
* drops a server-to-server request and its duplicate auth, and the contract
11+
* parse is what guarantees the hydrated entry matches a client fetch. Prefetches
12+
* still on this helper have not been converted; a converted one must prove the
13+
* viewer itself, since the route's own authorization no longer runs.
14+
*/
15+
export async function prefetchInternalJson<T>(path: string): Promise<T> {
16+
const cookie = (await headers()).get('cookie')
17+
// boundary-raw-fetch: server-side RSC prefetch forwarding the session cookie to an internal API route; requestJson is client-only and cannot run here
18+
const response = await fetch(`${getInternalApiBaseUrl()}${path}`, {
19+
headers: cookie ? { cookie } : {},
20+
})
21+
if (!response.ok) {
22+
throw new Error(`Prefetch failed for ${path}: ${response.status}`)
23+
}
24+
return response.json() as Promise<T>
25+
}

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

Lines changed: 16 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const {
1212
mockListFoldersForWorkspace,
1313
mockListInternalKnowledgeBases,
1414
mockListPinnedItemsForUser,
15-
mockListTables,
15+
mockPrefetchInternalJson,
1616
mockListWorkspaceFileFolders,
1717
mockListWorkspaceFilesWithShares,
1818
} = vi.hoisted(() => ({
@@ -23,7 +23,7 @@ const {
2323
mockListFoldersForWorkspace: vi.fn(),
2424
mockListInternalKnowledgeBases: vi.fn(),
2525
mockListPinnedItemsForUser: vi.fn(),
26-
mockListTables: vi.fn(),
26+
mockPrefetchInternalJson: vi.fn(),
2727
mockListWorkspaceFileFolders: vi.fn(),
2828
mockListWorkspaceFilesWithShares: vi.fn(),
2929
}))
@@ -46,22 +46,8 @@ vi.mock('@/lib/pinned-items/queries', () => ({
4646
vi.mock('@/lib/workspaces/permissions/utils', () => ({
4747
getWorkspaceMemberProfiles: mockGetWorkspaceMemberProfiles,
4848
}))
49-
/**
50-
* The barrel is mocked rather than `@/lib/table/wire`, so the prefetch's real
51-
* `toTableListItem` projection runs and the wire-shape assertions below are
52-
* meaningful rather than mocked away.
53-
*/
54-
vi.mock('@/lib/table', () => ({
55-
listTables: mockListTables,
56-
}))
57-
/**
58-
* `typeMetadataOf` is the one leaf of the real wire projection that reaches the
59-
* column-type registry, and through it every type module's icon and editor. Stub
60-
* that leaf only, so `toTableListItem`'s timestamp, `metadata`, and job
61-
* normalization stay under test rather than being mocked away wholesale.
62-
*/
63-
vi.mock('@/lib/table/column-types', () => ({
64-
typeMetadataOf: () => ({}),
49+
vi.mock('@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch', () => ({
50+
prefetchInternalJson: mockPrefetchInternalJson,
6551
}))
6652
vi.mock('@/lib/api/server/routes', () => ({
6753
internalSessionAuth: { authenticate: mockAuthenticate },
@@ -104,7 +90,7 @@ describe('workspace list prefetches', () => {
10490
mockListWorkspaceFileFolders.mockResolvedValue([])
10591
mockListPinnedItemsForUser.mockResolvedValue([])
10692
mockGetWorkspaceMemberProfiles.mockResolvedValue([])
107-
mockListTables.mockResolvedValue([])
93+
mockPrefetchInternalJson.mockResolvedValue({ data: { tables: [] } })
10894
mockAuthenticate.mockResolvedValue({ kind: 'session', userId: USER_ID, sessionId: 'sess-1' })
10995
mockListInternalKnowledgeBases.mockResolvedValue({ knowledgeBases: [] })
11096
mockKnowledgePresenterList.mockReturnValue({ success: true, data: [] })
@@ -197,73 +183,24 @@ describe('workspace list prefetches', () => {
197183
})
198184

199185
describe('prefetchTables', () => {
200-
const TABLE_ROW = {
201-
id: 't-1',
202-
name: 'people',
203-
description: null,
204-
schema: { columns: [{ id: 'c1', name: 'name', type: 'string' }] },
205-
metadata: { columnWidths: { c1: 120 } },
206-
rowCount: 3,
207-
maxRows: 10_000,
208-
workspaceId: WORKSPACE_ID,
209-
folderId: null,
210-
createdBy: 'u-1',
211-
locks: {
212-
schemaLocked: false,
213-
insertLocked: false,
214-
updateLocked: false,
215-
deleteLocked: false,
216-
},
217-
archivedAt: null,
218-
createdAt: new Date('2026-01-01T00:00:00.000Z'),
219-
updatedAt: new Date('2026-01-02T00:00:00.000Z'),
220-
}
221-
222-
it('reads tables from the data layer rather than over the wire', async () => {
223-
mockListTables.mockResolvedValue([TABLE_ROW])
224-
const client = makeClient()
225-
226-
await prefetchTables(client, WORKSPACE_ID, USER_ID)
227-
228-
expect(mockListTables).toHaveBeenCalledWith(WORKSPACE_ID, { scope: 'active' })
229-
})
230-
231186
/**
232-
* `listTablesContract`'s response schema is a passthrough `z.custom`, so a client fetch
233-
* caches the route's JSON verbatim. Seeding the raw data-layer row would put `Date`s and
234-
* the server-only `metadata` field under a key the hook never sees them on.
187+
* The tables list is the one read on this page still served over HTTP: `listTables` lives in
188+
* a module graph that reaches the executable tool registry, which
189+
* `check:tool-registry-boundary` refuses to let into a page graph.
235190
*/
236-
it('seeds the wire shape a client fetch caches, not the raw data-layer row', async () => {
237-
mockListTables.mockResolvedValue([TABLE_ROW])
238-
const client = makeClient()
239-
240-
await prefetchTables(client, WORKSPACE_ID, USER_ID)
241-
242-
const [cached] = client.getQueryData(tableKeys.list(WORKSPACE_ID, 'active')) as Array<
243-
Record<string, unknown>
244-
>
245-
expect(cached.createdAt).toBe('2026-01-01T00:00:00.000Z')
246-
expect(cached.updatedAt).toBe('2026-01-02T00:00:00.000Z')
247-
expect(cached.archivedAt).toBeNull()
248-
expect(cached).not.toHaveProperty('metadata')
249-
expect(cached.schema).toEqual({
250-
columns: [{ id: 'c1', name: 'name', type: 'string', required: false, unique: false }],
251-
})
252-
expect(cached.jobStatus).toBeNull()
253-
expect(cached.jobRowsProcessed).toBe(0)
254-
})
255-
256-
it('caches no tables when the viewer cannot be proved', async () => {
257-
mockGetWorkspaceHostContextForViewer.mockResolvedValue(null)
191+
it('primes the exact key useTablesList reads and unwraps data.tables', async () => {
192+
const tables = [{ id: 't-1' }]
193+
mockPrefetchInternalJson.mockResolvedValue({ data: { tables } })
258194
const client = makeClient()
259195

260196
await prefetchTables(client, WORKSPACE_ID, USER_ID)
261197

262-
expect(mockListTables).not.toHaveBeenCalled()
263-
expect(client.getQueryData(tableKeys.list(WORKSPACE_ID, 'active'))).toBeUndefined()
198+
expect(mockPrefetchInternalJson).toHaveBeenCalledWith(
199+
`/api/table?workspaceId=${WORKSPACE_ID}&scope=active`
200+
)
201+
expect(client.getQueryData(tableKeys.list(WORKSPACE_ID, 'active'))).toEqual(tables)
264202
})
265203
})
266-
267204
describe('prefetchFilesBrowser', () => {
268205
it('primes the folder key the client hook reads', async () => {
269206
const folders = [{ id: 'folder-1' }]
@@ -398,7 +335,7 @@ describe('workspace list prefetches', () => {
398335
const boom = new Error('500')
399336
mockListWorkspaceFilesWithShares.mockRejectedValue(boom)
400337
mockListFoldersForWorkspace.mockRejectedValue(boom)
401-
mockListTables.mockRejectedValue(boom)
338+
mockPrefetchInternalJson.mockRejectedValue(boom)
402339
mockListInternalKnowledgeBases.mockRejectedValue(boom)
403340
mockListPinnedItemsForUser.mockRejectedValue(boom)
404341
mockGetWorkspaceMemberProfiles.mockRejectedValue(boom)

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

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type { QueryClient } from '@tanstack/react-query'
2-
import { listTables } from '@/lib/table'
3-
import { toTableListItem } from '@/lib/table/wire'
4-
import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
2+
import type { TableDefinition } from '@/lib/table/types'
3+
import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch'
54
import { prefetchResourceFolders } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-folders'
65
import { prefetchResourceListChrome } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome'
76
import { TABLE_LIST_STALE_TIME, tableKeys } from '@/hooks/queries/utils/table-keys'
@@ -14,36 +13,30 @@ import { TABLE_LIST_STALE_TIME, tableKeys } from '@/hooks/queries/utils/table-ke
1413
* only placed correctly relative to the folder rows it sits beside, so
1514
* prefetching one without the other still flashes an ungrouped list.
1615
*
17-
* Both read the data layer directly, with no internal HTTP hop. Folders are
18-
* mapped with the same `mapFolder` the hook applies, matching the workspace
19-
* sidebar prefetch. Tables go through {@link toTableListItem}, the projection
20-
* `GET /api/table` itself returns — table definitions carry `Date` fields whose
21-
* *serialized* form is what the client caches, and the list contract's response
22-
* schema is a passthrough that neither coerces nor strips, so seeding raw rows
23-
* would put `Date` objects under a key a client fetch fills with ISO strings.
16+
* The tables list is the one read on this page still served over HTTP rather than from the data
17+
* layer, and deliberately so. `listTables` lives in `lib/table/service`, whose module graph
18+
* reaches `workflow-columns` — by several independent paths, including `jobs/service` and
19+
* `rows/service` — and through it the executor and the executable tool registry. Importing it
20+
* here put ~4,700 modules into this page's server graph, which `check:tool-registry-boundary`
21+
* catches. Converting this read means untangling `lib/table`'s internals first; until then the
22+
* route stays the cheaper option. See {@link prefetchInternalJson}.
2423
*
25-
* Neither read carries authorization of its own, so the viewer is proved first.
26-
* `getWorkspaceHostContextForViewer` resolves the same effective workspace
27-
* permission the route's own check does (both bottom out in
28-
* `checkWorkspaceAccess`), and it is `cache`d and already resolved by the layout
29-
* for this request, so it costs no additional queries. A viewer without access
30-
* caches nothing and the client fetch reaches the route for the real 403.
24+
* Folders and the chrome reads both go through the data layer and prove the viewer themselves,
25+
* so an unproven viewer caches nothing and their client fetch reaches the route for the real 403.
3126
*/
3227
export async function prefetchTables(
3328
queryClient: QueryClient,
3429
workspaceId: string,
3530
userId: string | undefined
3631
): Promise<void> {
37-
if (!userId) return
38-
const hostContext = await getWorkspaceHostContextForViewer(workspaceId, userId)
39-
if (!hostContext) return
40-
4132
await Promise.all([
4233
queryClient.prefetchQuery({
4334
queryKey: tableKeys.list(workspaceId, 'active'),
4435
queryFn: async () => {
45-
const tables = await listTables(workspaceId, { scope: 'active' })
46-
return tables.map(toTableListItem)
36+
const response = await prefetchInternalJson<{ data: { tables: TableDefinition[] } }>(
37+
`/api/table?workspaceId=${workspaceId}&scope=active`
38+
)
39+
return response.data.tables
4740
},
4841
staleTime: TABLE_LIST_STALE_TIME,
4942
}),

apps/sim/lib/table/service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ import {
5555
UNLOCKED_TABLE_LOCKS,
5656
} from '@/lib/table/types'
5757
import { validateTableName, validateTableSchema } from '@/lib/table/validation'
58-
import { stripGroupDeps } from '@/lib/table/workflow-columns'
58+
import { stripGroupDeps } from '@/lib/table/workflow-group-deps'
5959

6060
const logger = createLogger('TableService')
6161

apps/sim/lib/table/workflow-columns.ts

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import type {
4141
TableSchema,
4242
WorkflowGroup,
4343
} from '@/lib/table/types'
44+
import { stripGroupDeps } from '@/lib/table/workflow-group-deps'
4445

4546
const logger = createLogger('WorkflowGroupScheduler')
4647

@@ -1082,23 +1083,6 @@ export async function runWorkflowColumn(opts: {
10821083
* doesn't see an empty object. Returns the same group reference when nothing
10831084
* changed.
10841085
*/
1085-
export function stripGroupDeps(group: WorkflowGroup, removed: ReadonlySet<string>): WorkflowGroup {
1086-
const cols = group.dependencies?.columns ?? []
1087-
const mappings = group.inputMappings ?? []
1088-
const filteredDeps = cols.filter((d) => !removed.has(d))
1089-
const filteredMappings = mappings.filter((m) => !removed.has(m.columnName))
1090-
const depsChanged = filteredDeps.length !== cols.length
1091-
const mappingsChanged = filteredMappings.length !== mappings.length
1092-
if (!depsChanged && !mappingsChanged) return group
1093-
const next: WorkflowGroup = { ...group }
1094-
if (depsChanged) {
1095-
next.dependencies = filteredDeps.length > 0 ? { columns: filteredDeps } : undefined
1096-
}
1097-
if (mappingsChanged) {
1098-
next.inputMappings = filteredMappings.length > 0 ? filteredMappings : undefined
1099-
}
1100-
return next
1101-
}
11021086

11031087
/**
11041088
* Validates schema-level invariants. Run on every `addTableColumn`,
@@ -1365,3 +1349,5 @@ export function assertValidSchema(schema: TableSchema, columnOrder: string[] | u
13651349
throw new OrchestrationError('validation', `Schema validation failed: ${errs.join('; ')}`)
13661350
}
13671351
}
1352+
1353+
export { stripGroupDeps }
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { WorkflowGroup } from '@/lib/table/types'
2+
3+
/**
4+
* Drops the given column ids from a workflow group's dependencies and input
5+
* mappings, returning the group unchanged when neither referenced them.
6+
*
7+
* A pure projection over the group, deliberately kept in its own leaf module
8+
* rather than alongside the group runtime in `workflow-columns`: that module
9+
* reaches the executor and, through it, the executable tool registry, so any
10+
* server graph importing this helper from there pays ~4,700 modules for a
11+
* function that only reshapes an object.
12+
*/
13+
export function stripGroupDeps(group: WorkflowGroup, removed: ReadonlySet<string>): WorkflowGroup {
14+
const cols = group.dependencies?.columns ?? []
15+
const mappings = group.inputMappings ?? []
16+
const filteredDeps = cols.filter((d) => !removed.has(d))
17+
const filteredMappings = mappings.filter((m) => !removed.has(m.columnName))
18+
const depsChanged = filteredDeps.length !== cols.length
19+
const mappingsChanged = filteredMappings.length !== mappings.length
20+
if (!depsChanged && !mappingsChanged) return group
21+
const next: WorkflowGroup = { ...group }
22+
if (depsChanged) {
23+
next.dependencies = filteredDeps.length > 0 ? { columns: filteredDeps } : undefined
24+
}
25+
if (mappingsChanged) {
26+
next.inputMappings = filteredMappings.length > 0 ? filteredMappings : undefined
27+
}
28+
return next
29+
}

0 commit comments

Comments
 (0)