From 2a3bdb34525aedd5a91264c2653ccca311f80cc6 Mon Sep 17 00:00:00 2001 From: Steve Korshakov Date: Wed, 27 Aug 2025 10:23:17 -0700 Subject: [PATCH] feat: implement v2 sessions sync endpoints with better pagination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GET /v2/sessions/active - returns only active sessions - Add GET /v2/sessions - cursor-based pagination with changedSince filter - Use consistent ID-based sorting (desc) for predictable pagination - changedSince is just a filter, doesn't affect pagination mechanics - Remove lastMessage field from all v2 endpoints for smaller payloads - Simple opaque cursor format: cursor_v1_{sessionId} 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- sources/app/api.ts | 147 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 144 insertions(+), 3 deletions(-) diff --git a/sources/app/api.ts b/sources/app/api.ts index 0e03d1a..cf08a7a 100644 --- a/sources/app/api.ts +++ b/sources/app/api.ts @@ -98,7 +98,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }> function (req, body, done) { try { const bodyStr = body as string; - + // Handle empty body case - common for DELETE, GET requests if (!bodyStr || bodyStr.trim() === '') { (req as any).rawBody = bodyStr; @@ -111,7 +111,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }> done(null, {}); return; } - + const json = JSON.parse(bodyStr); // Store raw body for webhook signature verification (req as any).rawBody = bodyStr; @@ -652,7 +652,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }> // Remove link from account and clear avatar await tx.account.update({ where: { id: userId }, - data: { + data: { githubUserId: null, avatar: Prisma.JsonNull } @@ -879,6 +879,147 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }> }); }); + // V2 Sessions API - Active sessions only + typed.get('/v2/sessions/active', { + preHandler: app.authenticate, + schema: { + querystring: z.object({ + limit: z.coerce.number().int().min(1).max(500).default(150) + }).optional() + } + }, async (request, reply) => { + const userId = request.userId; + const limit = request.query?.limit || 150; + + const sessions = await db.session.findMany({ + where: { + accountId: userId, + active: true, + lastActiveAt: { gt: new Date(Date.now() - 1000 * 60 * 5) /* 5 minutes */ } + }, + orderBy: { lastActiveAt: 'desc' }, + take: limit, + select: { + id: true, + seq: true, + createdAt: true, + updatedAt: true, + metadata: true, + metadataVersion: true, + agentState: true, + agentStateVersion: true, + active: true, + lastActiveAt: true, + } + }); + + return reply.send({ + sessions: sessions.map((v) => ({ + id: v.id, + seq: v.seq, + createdAt: v.createdAt.getTime(), + updatedAt: v.updatedAt.getTime(), + active: v.active, + activeAt: v.lastActiveAt.getTime(), + metadata: v.metadata, + metadataVersion: v.metadataVersion, + agentState: v.agentState, + agentStateVersion: v.agentStateVersion, + })) + }); + }); + + // V2 Sessions API - Cursor-based pagination with change tracking + typed.get('/v2/sessions', { + preHandler: app.authenticate, + schema: { + querystring: z.object({ + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(200).default(50), + changedSince: z.coerce.number().int().positive().optional() + }).optional() + } + }, async (request, reply) => { + const userId = request.userId; + const { cursor, limit = 50, changedSince } = request.query || {}; + + // Decode cursor - simple ID-based cursor + let cursorSessionId: string | undefined; + if (cursor) { + if (cursor.startsWith('cursor_v1_')) { + cursorSessionId = cursor.substring(10); + } else { + return reply.code(400).send({ error: 'Invalid cursor format' }); + } + } + + // Build where clause + const where: Prisma.SessionWhereInput = { accountId: userId }; + + // Add changedSince filter (just a filter, doesn't affect pagination) + if (changedSince) { + where.updatedAt = { + gt: new Date(changedSince) + }; + } + + // Add cursor pagination - always by ID descending (most recent first) + if (cursorSessionId) { + where.id = { + lt: cursorSessionId // Get sessions with ID less than cursor (for desc order) + }; + } + + // Always sort by ID descending for consistent pagination + const orderBy = { id: 'desc' as const }; + + const sessions = await db.session.findMany({ + where, + orderBy, + take: limit + 1, // Fetch one extra to determine if there are more + select: { + id: true, + seq: true, + createdAt: true, + updatedAt: true, + metadata: true, + metadataVersion: true, + agentState: true, + agentStateVersion: true, + active: true, + lastActiveAt: true, + } + }); + + // Check if there are more results + const hasNext = sessions.length > limit; + const resultSessions = hasNext ? sessions.slice(0, limit) : sessions; + + // Generate next cursor - simple ID-based cursor + let nextCursor: string | null = null; + if (hasNext && resultSessions.length > 0) { + const lastSession = resultSessions[resultSessions.length - 1]; + nextCursor = `cursor_v1_${lastSession.id}`; + } + + return reply.send({ + sessions: resultSessions.map((v) => ({ + id: v.id, + seq: v.seq, + createdAt: v.createdAt.getTime(), + updatedAt: v.updatedAt.getTime(), + active: v.active, + activeAt: v.lastActiveAt.getTime(), + metadata: v.metadata, + metadataVersion: v.metadataVersion, + agentState: v.agentState, + agentStateVersion: v.agentStateVersion, + })), + nextCursor, + hasNext + }); + }); + // Create or load session by tag typed.post('/v1/sessions', { schema: {