From cb99aa7cf01fa19bcfa3e8d67bf020900aa9f2d5 Mon Sep 17 00:00:00 2001 From: guoyangzhen Date: Sat, 14 Mar 2026 20:17:50 +0800 Subject: [PATCH 1/2] fix: add pagination to GET /api/workflows (#3435) --- apps/sim/app/api/workflows/route.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/workflows/route.ts b/apps/sim/app/api/workflows/route.ts index 611d808cf61..fdba07edb32 100644 --- a/apps/sim/app/api/workflows/route.ts +++ b/apps/sim/app/api/workflows/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' 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' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' @@ -27,6 +27,9 @@ export async function GET(request: NextRequest) { const startTime = Date.now() const url = new URL(request.url) 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) try { const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -66,12 +69,16 @@ export async function GET(request: NextRequest) { const orderByClause = [asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)] + // Fetch limit+1 to detect if there are more pages + const fetchLimit = limit + 1 + if (workspaceId) { workflows = await db .select() .from(workflow) .where(eq(workflow.workspaceId, workspaceId)) .orderBy(...orderByClause) + .limit(fetchLimit) } else { const workspacePermissionRows = await db .select({ workspaceId: permissions.entityId }) @@ -86,9 +93,15 @@ export async function GET(request: NextRequest) { .from(workflow) .where(inArray(workflow.workspaceId, workspaceIds)) .orderBy(...orderByClause) + .limit(fetchLimit) } - return NextResponse.json({ data: workflows }, { status: 200 }) + // 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 + + return NextResponse.json({ data, nextCursor }, { status: 200 }) } catch (error: any) { const elapsed = Date.now() - startTime logger.error(`[${requestId}] Workflow fetch error after ${elapsed}ms`, error) From 3a453a2101a3180280c961a54b126f6e9eb8c368 Mon Sep 17 00:00:00 2001 From: guoyangzhen Date: Sat, 14 Mar 2026 21:01:26 +0800 Subject: [PATCH 2/2] fix: apply cursor to pagination queries (addressing review feedback) - 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 --- apps/sim/app/api/workflows/route.ts | 52 ++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/api/workflows/route.ts b/apps/sim/app/api/workflows/route.ts index fdba07edb32..b1d0aa22b0a 100644 --- a/apps/sim/app/api/workflows/route.ts +++ b/apps/sim/app/api/workflows/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { permissions, workflow, workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, asc, eq, gt, inArray, isNull, min } from 'drizzle-orm' +import { and, asc, eq, gt, gte, inArray, isNull, lt, min, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' @@ -72,11 +72,43 @@ export async function GET(request: NextRequest) { // Fetch limit+1 to detect if there are more pages const fetchLimit = limit + 1 + // Build cursor condition for keyset pagination + // Cursor is base64-encoded JSON: { s: sortOrder, c: createdAt, i: id } + let cursorCondition = null + if (cursor) { + try { + const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString()) + const cursorSortOrder = decoded.s + const cursorCreatedAt = new Date(decoded.c) + const cursorId = decoded.i + // Keyset pagination for ORDER BY sortOrder ASC, createdAt ASC, id ASC: + // (sortOrder > cursorSortOrder) OR + // (sortOrder = cursorSortOrder AND createdAt > cursorCreatedAt) OR + // (sortOrder = cursorSortOrder AND createdAt = cursorCreatedAt AND id > cursorId) + cursorCondition = or( + gt(workflow.sortOrder, cursorSortOrder), + and( + eq(workflow.sortOrder, cursorSortOrder), + or( + gt(workflow.createdAt, cursorCreatedAt), + and(eq(workflow.createdAt, cursorCreatedAt), gt(workflow.id, cursorId)) + ) + ) + ) + } catch { + // Invalid cursor - ignore and return first page + } + } + if (workspaceId) { + const whereClause = cursorCondition + ? and(eq(workflow.workspaceId, workspaceId), cursorCondition) + : eq(workflow.workspaceId, workspaceId) + workflows = await db .select() .from(workflow) - .where(eq(workflow.workspaceId, workspaceId)) + .where(whereClause) .orderBy(...orderByClause) .limit(fetchLimit) } else { @@ -88,18 +120,28 @@ export async function GET(request: NextRequest) { if (workspaceIds.length === 0) { return NextResponse.json({ data: [] }, { status: 200 }) } + const whereClause = cursorCondition + ? and(inArray(workflow.workspaceId, workspaceIds), cursorCondition) + : inArray(workflow.workspaceId, workspaceIds) + workflows = await db .select() .from(workflow) - .where(inArray(workflow.workspaceId, workspaceIds)) + .where(whereClause) .orderBy(...orderByClause) .limit(fetchLimit) } - // Determine if there are more results and set cursor + // Determine if there are more results and compute next cursor const hasMore = workflows.length > limit const data = hasMore ? workflows.slice(0, limit) : workflows - const nextCursor = hasMore ? data[data.length - 1]?.id : null + let nextCursor = null + if (hasMore && data.length > 0) { + const last = data[data.length - 1] + nextCursor = Buffer.from( + JSON.stringify({ s: last.sortOrder, c: last.createdAt, i: last.id }) + ).toString('base64') + } return NextResponse.json({ data, nextCursor }, { status: 200 }) } catch (error: any) {