feat: implement v2 sessions sync endpoints with better pagination
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
0f222cfd98
commit
2a3bdb3452
@ -98,7 +98,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
|
|||||||
function (req, body, done) {
|
function (req, body, done) {
|
||||||
try {
|
try {
|
||||||
const bodyStr = body as string;
|
const bodyStr = body as string;
|
||||||
|
|
||||||
// Handle empty body case - common for DELETE, GET requests
|
// Handle empty body case - common for DELETE, GET requests
|
||||||
if (!bodyStr || bodyStr.trim() === '') {
|
if (!bodyStr || bodyStr.trim() === '') {
|
||||||
(req as any).rawBody = bodyStr;
|
(req as any).rawBody = bodyStr;
|
||||||
@ -111,7 +111,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
|
|||||||
done(null, {});
|
done(null, {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = JSON.parse(bodyStr);
|
const json = JSON.parse(bodyStr);
|
||||||
// Store raw body for webhook signature verification
|
// Store raw body for webhook signature verification
|
||||||
(req as any).rawBody = bodyStr;
|
(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
|
// Remove link from account and clear avatar
|
||||||
await tx.account.update({
|
await tx.account.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: {
|
data: {
|
||||||
githubUserId: null,
|
githubUserId: null,
|
||||||
avatar: Prisma.JsonNull
|
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
|
// Create or load session by tag
|
||||||
typed.post('/v1/sessions', {
|
typed.post('/v1/sessions', {
|
||||||
schema: {
|
schema: {
|
||||||
|
Loading…
Reference in New Issue
Block a user