fix: add pagination to GET /api/workflows to prevent OOM on large workspaces#3578
fix: add pagination to GET /api/workflows to prevent OOM on large workspaces#3578guoyangzhen wants to merge 2 commits intosimstudioai:mainfrom
Conversation
PR SummaryMedium Risk Overview The endpoint now accepts Written by Cursor Bugbot for commit 3a453a2. This will update automatically on new commits. Configure here. |
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
Greptile SummaryThis PR adds limit-based pagination to Key issues found:
Confidence Score: 2/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant Client
participant GET /api/workflows
participant DB
Client->>GET /api/workflows: GET ?workspaceId=X&limit=100&cursor=<id>
Note over GET /api/workflows: Parses limit (clamped 1–500)<br/>Parses cursor (read but UNUSED)
GET /api/workflows->>DB: SELECT * FROM workflow<br/>WHERE workspaceId = X<br/>ORDER BY sortOrder, createdAt, id<br/>LIMIT 101
DB-->>GET /api/workflows: Up to 101 rows
Note over GET /api/workflows: hasMore = rows.length > 100<br/>data = rows.slice(0, 100)<br/>nextCursor = data[last].id (if hasMore)
GET /api/workflows-->>Client: { data: [...], nextCursor: <id> | null }
Note over Client: Client sends next request with cursor=<id><br/>but server ignores cursor — returns page 1 again
Last reviewed commit: cb99aa7 |
| const startTime = Date.now() | ||
| const url = new URL(request.url) | ||
| const workspaceId = url.searchParams.get('workspaceId') | ||
| const cursor = url.searchParams.get('cursor') |
There was a problem hiding this comment.
cursor parsed but never applied to the query
The cursor variable is read from the query params but is never used in either of the two database queries. As a result, passing ?cursor=<id> is silently ignored and the server always returns the first page — making cursor-based pagination completely non-functional for callers who attempt to use it.
The gt operator imported on line 4 was presumably intended for a WHERE clause like .where(and(...existingWhere..., gt(workflow.id, cursor))), but that call was never added. Since gt is also unused, this will trigger a lint/TypeScript error.
To properly implement cursor-based offset, the WHERE clause needs to be extended, e.g.:
// In the workspaceId branch
.where(
cursor
? and(eq(workflow.workspaceId, workspaceId), gt(workflow.id, cursor))
: eq(workflow.workspaceId, workspaceId)
)Note also that using gt(workflow.id, cursor) only gives correct pagination when ordering solely by id. With the current compound ordering [asc(sortOrder), asc(createdAt), asc(id)], a pure id > cursor filter will skip or repeat rows where sortOrder or createdAt differs between pages. A correct keyset cursor needs to encode all three sort columns.
apps/sim/app/api/workflows/route.ts
Outdated
| import { permissions, workflow, workflowFolder } from '@sim/db/schema' | ||
| import { createLogger } from '@sim/logger' | ||
| import { and, asc, eq, inArray, isNull, min } from 'drizzle-orm' | ||
| import { and, asc, eq, gt, inArray, isNull, min } from 'drizzle-orm' |
There was a problem hiding this comment.
Unused gt import
gt is imported from drizzle-orm but is never referenced in the file. This will produce a lint/compile warning and should be removed until the cursor WHERE clause is actually implemented.
| import { and, asc, eq, gt, inArray, isNull, min } from 'drizzle-orm' | |
| import { and, asc, eq, inArray, isNull, min } from 'drizzle-orm' |
apps/sim/app/api/workflows/route.ts
Outdated
| // Determine if there are more results and set cursor | ||
| const hasMore = workflows.length > limit | ||
| const data = hasMore ? workflows.slice(0, limit) : workflows | ||
| const nextCursor = hasMore ? data[data.length - 1]?.id : null |
There was a problem hiding this comment.
Cursor value does not match sort order — pagination will produce incorrect results
The nextCursor is set to the last item's id:
const nextCursor = hasMore ? data[data.length - 1]?.id : nullBut the query is ordered by [asc(sortOrder), asc(createdAt), asc(id)]. When the cursor is eventually applied as gt(workflow.id, cursor), this filter is only semantically correct when ordering by id alone. In the current compound ordering, rows with a smaller sortOrder but a larger id than the cursor will be excluded from page 2 even though they should appear, and vice-versa.
To avoid skipping or duplicating results, the cursor must encode the full sort key (e.g., sortOrder, createdAt, and id combined — typically as a base64-encoded JSON or composite string), and the WHERE clause must replicate the tie-breaking logic of the ORDER BY.
- Parse base64 cursor containing {sortOrder, createdAt, id}
- Build keyset pagination condition for composite ORDER BY
- Encode nextCursor as composite value matching sort order
- Remove unused 'gt' import
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
| const workspaceId = url.searchParams.get('workspaceId') | ||
| const cursor = url.searchParams.get('cursor') | ||
| const limitParam = url.searchParams.get('limit') | ||
| const limit = Math.min(Math.max(parseInt(limitParam || '100', 10) || 100, 1), 500) |
There was a problem hiding this comment.
Silent data truncation for callers expecting all workflows
High Severity
The new default limit of 100 silently truncates results for all existing callers, none of which handle nextCursor pagination. fetchWorkflows in workflows.ts only loads the first 100 workflows into the sidebar registry. use-export-workspace.ts only exports the first 100 workflows, producing silently incomplete ZIP backups. fetchDeployedWorkflows in workflow-mcp-servers.ts may miss deployed workflows. These are the exact large-workspace scenarios this PR targets.


Problem
The
GET /api/workflowsendpoint returned ALL workflows without any limit, causing Node.js OOM crashes when workspaces have thousands of workflows. Also caused browser freezes from parsing massive JSON payloads.Fix
limitquery param (default 100, max 500)cursorquery param for future pagination supportlimit+1to detect if more pages existnextCursorin response when more results availableBackward Compatibility
dataarray still returned,nextCursoris additiveFixes #3435