Skip to content

Commit be9c5df

Browse files
MaxwellCalkinclaude
andcommitted
fix: add pagination to GET /api/workflows to prevent memory exhaustion
The GET /api/workflows endpoint fetches all workflows for a user or workspace without any LIMIT, causing Node.js OOM crashes and browser freezes on large workspaces. This adds offset/limit pagination with a default page size of 200 (max 500), returns total count and hasMore metadata, and updates all frontend callers to page through results. Fixes #3435 > Note: This PR was authored by an AI (Claude Opus 4.6, Anthropic). > I am pursuing employment as an AI contributor — transparently, not > impersonating a human. See https://claude.ai for more about me. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8c0a2e0 commit be9c5df

File tree

6 files changed

+248
-35
lines changed

6 files changed

+248
-35
lines changed

apps/sim/app/api/workflows/route.test.ts

Lines changed: 143 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,22 @@ const {
1111
mockWorkflowCreated,
1212
mockDbSelect,
1313
mockDbInsert,
14+
mockWorkspaceExists,
15+
mockVerifyWorkspaceMembership,
1416
} = vi.hoisted(() => ({
1517
mockCheckSessionOrInternalAuth: vi.fn(),
1618
mockGetUserEntityPermissions: vi.fn(),
1719
mockWorkflowCreated: vi.fn(),
1820
mockDbSelect: vi.fn(),
1921
mockDbInsert: vi.fn(),
22+
mockWorkspaceExists: vi.fn(),
23+
mockVerifyWorkspaceMembership: vi.fn(),
2024
}))
2125

2226
vi.mock('drizzle-orm', () => ({
2327
...drizzleOrmMock,
2428
min: vi.fn((field) => ({ type: 'min', field })),
29+
count: vi.fn(() => ({ type: 'count' })),
2530
}))
2631

2732
vi.mock('@sim/db', () => ({
@@ -71,11 +76,11 @@ vi.mock('@/lib/auth/hybrid', () => ({
7176

7277
vi.mock('@/lib/workspaces/permissions/utils', () => ({
7378
getUserEntityPermissions: (...args: unknown[]) => mockGetUserEntityPermissions(...args),
74-
workspaceExists: vi.fn(),
79+
workspaceExists: (...args: unknown[]) => mockWorkspaceExists(...args),
7580
}))
7681

7782
vi.mock('@/app/api/workflows/utils', () => ({
78-
verifyWorkspaceMembership: vi.fn(),
83+
verifyWorkspaceMembership: (...args: unknown[]) => mockVerifyWorkspaceMembership(...args),
7984
}))
8085

8186
vi.mock('@/lib/core/telemetry', () => ({
@@ -84,7 +89,7 @@ vi.mock('@/lib/core/telemetry', () => ({
8489
},
8590
}))
8691

87-
import { POST } from '@/app/api/workflows/route'
92+
import { GET, POST } from '@/app/api/workflows/route'
8893

8994
describe('Workflows API Route - POST ordering', () => {
9095
beforeEach(() => {
@@ -171,3 +176,138 @@ describe('Workflows API Route - POST ordering', () => {
171176
expect(insertedValues?.sortOrder).toBe(0)
172177
})
173178
})
179+
180+
describe('Workflows API Route - GET pagination', () => {
181+
beforeEach(() => {
182+
vi.clearAllMocks()
183+
184+
mockCheckSessionOrInternalAuth.mockResolvedValue({
185+
success: true,
186+
userId: 'user-123',
187+
userName: 'Test User',
188+
userEmail: 'test@example.com',
189+
})
190+
mockWorkspaceExists.mockResolvedValue(true)
191+
mockVerifyWorkspaceMembership.mockResolvedValue('member')
192+
})
193+
194+
/**
195+
* Builds a fluent mock chain for db.select() that terminates with the
196+
* given resolved values. The chain supports arbitrary method calls
197+
* (from, where, orderBy, limit, offset) in any order.
198+
*/
199+
function buildSelectChain(resolvedValues: unknown[]) {
200+
const chain: Record<string, unknown> = {}
201+
const self = new Proxy(chain, {
202+
get(_target, prop) {
203+
if (prop === 'then') {
204+
return (resolve: (v: unknown) => void) => resolve(resolvedValues)
205+
}
206+
return vi.fn().mockReturnValue(self)
207+
},
208+
})
209+
return self
210+
}
211+
212+
it('returns pagination metadata with workspace workflows', async () => {
213+
const mockWorkflows = [
214+
{ id: 'wf-1', name: 'Workflow 1', workspaceId: 'ws-1' },
215+
{ id: 'wf-2', name: 'Workflow 2', workspaceId: 'ws-1' },
216+
]
217+
218+
const selectCalls: unknown[][] = []
219+
mockDbSelect.mockImplementation((...args: unknown[]) => {
220+
selectCalls.push(args)
221+
if (selectCalls.length === 1) {
222+
return buildSelectChain([{ count: 2 }])
223+
}
224+
return buildSelectChain(mockWorkflows)
225+
})
226+
227+
const req = createMockRequest(
228+
'GET',
229+
undefined,
230+
{},
231+
'http://localhost:3000/api/workflows?workspaceId=ws-1'
232+
)
233+
234+
const response = await GET(req as any)
235+
const json = await response.json()
236+
237+
expect(response.status).toBe(200)
238+
expect(json.data).toHaveLength(2)
239+
expect(json.pagination).toBeDefined()
240+
expect(json.pagination.total).toBe(2)
241+
expect(json.pagination.limit).toBe(200)
242+
expect(json.pagination.offset).toBe(0)
243+
expect(json.pagination.hasMore).toBe(false)
244+
})
245+
246+
it('respects custom limit and offset params', async () => {
247+
const mockWorkflows = [{ id: 'wf-1', name: 'Workflow 1', workspaceId: 'ws-1' }]
248+
249+
const selectCalls: unknown[][] = []
250+
mockDbSelect.mockImplementation((...args: unknown[]) => {
251+
selectCalls.push(args)
252+
if (selectCalls.length === 1) {
253+
return buildSelectChain([{ count: 5 }])
254+
}
255+
return buildSelectChain(mockWorkflows)
256+
})
257+
258+
const req = createMockRequest(
259+
'GET',
260+
undefined,
261+
{},
262+
'http://localhost:3000/api/workflows?workspaceId=ws-1&limit=1&offset=2'
263+
)
264+
265+
const response = await GET(req as any)
266+
const json = await response.json()
267+
268+
expect(response.status).toBe(200)
269+
expect(json.pagination.limit).toBe(1)
270+
expect(json.pagination.offset).toBe(2)
271+
expect(json.pagination.total).toBe(5)
272+
expect(json.pagination.hasMore).toBe(true)
273+
})
274+
275+
it('clamps limit to MAX_PAGE_LIMIT', async () => {
276+
const selectCalls: unknown[][] = []
277+
mockDbSelect.mockImplementation((...args: unknown[]) => {
278+
selectCalls.push(args)
279+
if (selectCalls.length === 1) {
280+
return buildSelectChain([{ count: 0 }])
281+
}
282+
return buildSelectChain([])
283+
})
284+
285+
const req = createMockRequest(
286+
'GET',
287+
undefined,
288+
{},
289+
'http://localhost:3000/api/workflows?workspaceId=ws-1&limit=9999'
290+
)
291+
292+
const response = await GET(req as any)
293+
const json = await response.json()
294+
295+
expect(response.status).toBe(200)
296+
expect(json.pagination.limit).toBe(500)
297+
})
298+
299+
it('returns pagination in empty workspace response for no-workspace query', async () => {
300+
mockDbSelect.mockImplementation(() => buildSelectChain([]))
301+
302+
const req = createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/workflows')
303+
304+
const response = await GET(req as any)
305+
const json = await response.json()
306+
307+
expect(response.status).toBe(200)
308+
expect(json.data).toEqual([])
309+
expect(json.pagination).toBeDefined()
310+
expect(json.pagination.total).toBe(0)
311+
expect(json.pagination.hasMore).toBe(false)
312+
})
313+
})

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

Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { db } from '@sim/db'
22
import { permissions, workflow, workflowFolder } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { and, asc, eq, inArray, isNull, min } from 'drizzle-orm'
4+
import { and, asc, count, eq, inArray, isNull, min } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { z } from 'zod'
77
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
@@ -12,6 +12,9 @@ import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
1212

1313
const logger = createLogger('WorkflowAPI')
1414

15+
const DEFAULT_PAGE_LIMIT = 200
16+
const MAX_PAGE_LIMIT = 500
17+
1518
const CreateWorkflowSchema = z.object({
1619
name: z.string().min(1, 'Name is required'),
1720
description: z.string().optional().default(''),
@@ -28,6 +31,14 @@ export async function GET(request: NextRequest) {
2831
const url = new URL(request.url)
2932
const workspaceId = url.searchParams.get('workspaceId')
3033

34+
const rawLimit = url.searchParams.get('limit')
35+
const rawOffset = url.searchParams.get('offset')
36+
const limit = Math.min(
37+
Math.max(1, rawLimit ? Number(rawLimit) : DEFAULT_PAGE_LIMIT),
38+
MAX_PAGE_LIMIT
39+
)
40+
const offset = Math.max(0, rawOffset ? Number(rawOffset) : 0)
41+
3142
try {
3243
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
3344
if (!auth.success || !auth.userId) {
@@ -63,32 +74,68 @@ export async function GET(request: NextRequest) {
6374
}
6475

6576
let workflows
77+
let total: number
6678

6779
const orderByClause = [asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)]
6880

6981
if (workspaceId) {
70-
workflows = await db
71-
.select()
72-
.from(workflow)
73-
.where(eq(workflow.workspaceId, workspaceId))
74-
.orderBy(...orderByClause)
82+
const whereCondition = eq(workflow.workspaceId, workspaceId)
83+
84+
const [countResult, workflowRows] = await Promise.all([
85+
db.select({ count: count() }).from(workflow).where(whereCondition),
86+
db
87+
.select()
88+
.from(workflow)
89+
.where(whereCondition)
90+
.orderBy(...orderByClause)
91+
.limit(limit)
92+
.offset(offset),
93+
])
94+
95+
total = countResult[0]?.count ?? 0
96+
workflows = workflowRows
7597
} else {
7698
const workspacePermissionRows = await db
7799
.select({ workspaceId: permissions.entityId })
78100
.from(permissions)
79101
.where(and(eq(permissions.userId, userId), eq(permissions.entityType, 'workspace')))
80102
const workspaceIds = workspacePermissionRows.map((row) => row.workspaceId)
81103
if (workspaceIds.length === 0) {
82-
return NextResponse.json({ data: [] }, { status: 200 })
104+
return NextResponse.json(
105+
{ data: [], pagination: { total: 0, limit, offset, hasMore: false } },
106+
{ status: 200 }
107+
)
83108
}
84-
workflows = await db
85-
.select()
86-
.from(workflow)
87-
.where(inArray(workflow.workspaceId, workspaceIds))
88-
.orderBy(...orderByClause)
109+
110+
const whereCondition = inArray(workflow.workspaceId, workspaceIds)
111+
112+
const [countResult, workflowRows] = await Promise.all([
113+
db.select({ count: count() }).from(workflow).where(whereCondition),
114+
db
115+
.select()
116+
.from(workflow)
117+
.where(whereCondition)
118+
.orderBy(...orderByClause)
119+
.limit(limit)
120+
.offset(offset),
121+
])
122+
123+
total = countResult[0]?.count ?? 0
124+
workflows = workflowRows
89125
}
90126

91-
return NextResponse.json({ data: workflows }, { status: 200 })
127+
return NextResponse.json(
128+
{
129+
data: workflows,
130+
pagination: {
131+
total,
132+
limit,
133+
offset,
134+
hasMore: offset + workflows.length < total,
135+
},
136+
},
137+
{ status: 200 }
138+
)
92139
} catch (error: any) {
93140
const elapsed = Date.now() - startTime
94141
logger.error(`[${requestId}] Workflow fetch error after ${elapsed}ms`, error)

apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workspace.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
sanitizePathSegment,
99
type WorkflowExportData,
1010
} from '@/lib/workflows/operations/import-export'
11+
import { fetchAllPages } from '@/hooks/queries/utils/paginated-fetch'
1112

1213
const logger = createLogger('useExportWorkspace')
1314

@@ -32,11 +33,9 @@ export function useExportWorkspace({ onSuccess }: UseExportWorkspaceProps = {})
3233
try {
3334
logger.info('Exporting workspace', { workspaceId })
3435

35-
const workflowsResponse = await fetch(`/api/workflows?workspaceId=${workspaceId}`)
36-
if (!workflowsResponse.ok) {
37-
throw new Error('Failed to fetch workflows')
38-
}
39-
const { data: workflows } = await workflowsResponse.json()
36+
const workflows = await fetchAllPages<Record<string, any>>(
37+
`/api/workflows?workspaceId=${workspaceId}`
38+
)
4039

4140
const foldersResponse = await fetch(`/api/folders?workspaceId=${workspaceId}`)
4241
if (!foldersResponse.ok) {
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* Fetches all pages from a paginated API endpoint.
3+
*
4+
* The endpoint is expected to return `{ data: T[], pagination: { hasMore: boolean } }`.
5+
* Pages are fetched sequentially until `hasMore` is `false`.
6+
*
7+
* @param baseUrl - Base URL including any existing query params (e.g. `/api/workflows?workspaceId=ws-1`)
8+
* @param pageSize - Number of items per page (default 200)
9+
* @returns All items concatenated across pages
10+
*/
11+
export async function fetchAllPages<T>(baseUrl: string, pageSize = 200): Promise<T[]> {
12+
const allItems: T[] = []
13+
let offset = 0
14+
const separator = baseUrl.includes('?') ? '&' : '?'
15+
16+
while (true) {
17+
const response = await fetch(`${baseUrl}${separator}limit=${pageSize}&offset=${offset}`)
18+
19+
if (!response.ok) {
20+
throw new Error(`Failed to fetch from ${baseUrl}: ${response.statusText}`)
21+
}
22+
23+
const json = await response.json()
24+
const data: T[] = json.data
25+
allItems.push(...data)
26+
27+
if (!json.pagination?.hasMore) {
28+
break
29+
}
30+
31+
offset += pageSize
32+
}
33+
34+
return allItems
35+
}

apps/sim/hooks/queries/workflow-mcp-servers.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
3+
import { fetchAllPages } from '@/hooks/queries/utils/paginated-fetch'
34

45
const logger = createLogger('WorkflowMcpServerQueries')
56

@@ -445,13 +446,7 @@ export function useDeleteWorkflowMcpTool() {
445446
* Fetch deployed workflows for a workspace
446447
*/
447448
async function fetchDeployedWorkflows(workspaceId: string): Promise<DeployedWorkflow[]> {
448-
const response = await fetch(`/api/workflows?workspaceId=${workspaceId}`)
449-
450-
if (!response.ok) {
451-
throw new Error('Failed to fetch workflows')
452-
}
453-
454-
const { data }: { data: any[] } = await response.json()
449+
const data = await fetchAllPages<Record<string, any>>(`/api/workflows?workspaceId=${workspaceId}`)
455450

456451
return data
457452
.filter((w) => w.isDeployed)

0 commit comments

Comments
 (0)