Skip to content

Commit 201a9e3

Browse files
Merge pull request #861 from gloskull/Add-cursor-pagination
Add cursor pagination
2 parents 9a94208 + e4d14ca commit 201a9e3

3 files changed

Lines changed: 60 additions & 78 deletions

File tree

src/routes/users.ts

Lines changed: 12 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -238,69 +238,23 @@ usersRouter.get(
238238
(res.locals.correlationId as string | undefined) ?? getRequestId();
239239

240240
try {
241-
const userId = req.user!.id;
242-
const result = await getCurrentUserProfile(userId);
243-
244-
if (!result.ok) {
245-
throw result.error;
246-
}
247-
248-
const profile = result.value;
249-
logger.info(
250-
{
251-
correlationId,
252-
userId,
253-
stellarAddress: profile.stellarAddress,
254-
...profile.totals,
255-
},
256-
"user_me_profile_loaded",
257-
);
258-
259-
// Strong ETag on the profile payload; 304 if client already has it.
260-
const responsePayload = { data: profile };
261-
if (conditionalGet(responsePayload, req, res)) return;
262-
return res.json(responsePayload);
263-
} catch (e) {
264-
return next(e);
241+
stellarAddressSchema.parse(address);
242+
} catch {
243+
return res.status(400).json({ error: { code: "invalid_address" } });
265244
}
266245
},
267246
);
268247

269-
// ---------------------------------------------------------------------------
270-
// GET /api/users/:address/predictions
271-
// ---------------------------------------------------------------------------
248+
const querySchema = z.object({
249+
status: z.enum(["pending", "confirmed", "won", "lost", "claimed"]).optional(),
250+
cursor: z
251+
.string()
252+
.regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\|[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i)
253+
.optional(),
254+
limit: z.coerce.number().int().min(1).max(100),
255+
});
272256

273-
/**
274-
* Returns a cursor-paginated list of predictions for the given Stellar address.
275-
*
276-
* Path parameters:
277-
* - :address — a valid 56-char Stellar G-address
278-
*
279-
* Query parameters:
280-
* - status (optional) — filter by prediction status enum
281-
* - cursor (optional) — opaque base64url token from the previous page's `nextCursor`
282-
* - limit (optional, default 20, max 100) — page size
283-
*
284-
* Response:
285-
* { data: UserPredictionRow[], nextCursor: string | null }
286-
*
287-
* Caching:
288-
* Strong ETag on the page payload; clients may revalidate with If-None-Match
289-
* and receive 304 Not Modified when the page is unchanged.
290-
*
291-
* Errors:
292-
* 400 invalid_address — path param is not a valid G… Stellar address
293-
* 400 validation_error — query params fail the zod schema
294-
* 404 not_found — no user row for that address
295-
*/
296-
usersRouter.get(
297-
"/:address/predictions",
298-
usersRateLimit,
299-
async (req: Request, res: Response, next: NextFunction) => {
300-
// Prefer the access-log correlation ID; fall back to ALS for non-route callers.
301-
const correlationId =
302-
(res.locals.correlationId as string | undefined) ?? getRequestId();
303-
const reqId = correlationId;
257+
const query = querySchema.parse({ status, cursor, limit });
304258

305259
try {
306260
// Validate the path parameter :address at the route boundary before touching the DB.

src/services/userService.ts

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { db } from "../db/client";
2-
import { users, predictions } from "../db/schema";
3-
import { and, eq, desc, count } from "drizzle-orm";
2+
import { users, predictions, markets, claims } from "../db/schema";
3+
import { and, eq, desc, lt, count, or } from "drizzle-orm";
44
import { Result, ok, err } from "../errors/RouteError";
55
import { encodeCursor, decodeCursor, clampLimit, DEFAULT_PAGE_SIZE } from "../utils/cursor";
66

@@ -161,29 +161,20 @@ export async function getUserPredictions(
161161
): Promise<Page<UserPredictionRow>> {
162162
const { status, limit, cursor } = opts;
163163

164-
// Base conditions — always scope to this user.
165-
const baseConditions = [eq(predictions.userId, userId)];
164+
const whereConditions = [eq(predictions.userId, userId)];
166165

167166
if (status) {
168167
baseConditions.push(eq(predictions.status, status));
169168
}
170169

171-
// Decode the opaque cursor. An invalid or version-mismatched token is
172-
// treated as absent so a tampered ?cursor= value never causes a 500.
173-
const cursorKey = decodeCursor(cursor);
170+
if (cursor) {
171+
const [cursorTime, cursorId] = cursor.split("|");
172+
const cursorCreatedAt = new Date(cursorTime);
174173

175-
if (cursorKey) {
176-
const cursorTime = new Date(cursorKey.sortValue);
177-
// Standard two-column keyset predicate for DESC (createdAt, id) ordering:
178-
// rows where createdAt is strictly earlier, OR same timestamp with a
179-
// lexicographically smaller UUID (which is also numerically earlier).
180-
baseConditions.push(
174+
whereConditions.push(
181175
or(
182-
lt(predictions.createdAt, cursorTime),
183-
and(
184-
eq(predictions.createdAt, cursorTime),
185-
lt(predictions.id, cursorKey.id),
186-
),
176+
lt(predictions.createdAt, cursorCreatedAt),
177+
and(eq(predictions.createdAt, cursorCreatedAt), lt(predictions.id, cursorId)),
187178
)!,
188179
);
189180
}

tests/users.test.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,17 @@ describe("GET /api/users/:address/predictions", () => {
4949
indexedLedger: 0,
5050
});
5151

52+
const baseTime = new Date("2026-01-01T00:00:00.000Z");
53+
5254
for (let i = 0; i < 25; i++) {
5355
await db.insert(predictions).values({
56+
id: `00000000-0000-4000-8000-${(i + 1).toString().padStart(12, "0")}`,
5457
marketId: "market-1",
5558
userId: user!.id,
5659
outcome: i % 2 === 0 ? "yes" : "no",
5760
amount: "100",
5861
status: i < 10 ? "pending" : i < 15 ? "confirmed" : "won",
59-
createdAt: new Date(Date.now() - i * 60 * 60 * 1000),
62+
createdAt: new Date(baseTime.getTime() - Math.floor(i / 2) * 60 * 60 * 1000),
6063
});
6164
}
6265
});
@@ -70,7 +73,7 @@ describe("GET /api/users/:address/predictions", () => {
7073

7174
it("should return 404 for unknown address", async () => {
7275
const res = await request(createApp()).get(
73-
"/api/users/GBUNKKNOWN000000000000000000000000000000000000000000000000/predictions"
76+
`/api/users/${"G" + "A".repeat(55)}/predictions`
7477
);
7578
expect(res.status).toBe(404);
7679
expect(res.body.error.code).toBe("not_found");
@@ -108,6 +111,40 @@ describe("GET /api/users/:address/predictions", () => {
108111
expect(page2.body.data.length).toBeGreaterThan(0);
109112
});
110113

114+
115+
it("should not skip predictions that share a cursor timestamp", async () => {
116+
const seenIds = new Set<string>();
117+
let cursor: string | null = null;
118+
119+
for (let page = 0; page < 9; page++) {
120+
const res = await request(createApp()).get(
121+
`/api/users/${testAddress}/predictions?limit=3${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`
122+
);
123+
124+
expect(res.status).toBe(200);
125+
for (const prediction of res.body.data) {
126+
expect(seenIds.has(prediction.id)).toBe(false);
127+
seenIds.add(prediction.id);
128+
}
129+
130+
cursor = res.body.nextCursor;
131+
if (!cursor) {
132+
break;
133+
}
134+
}
135+
136+
expect(seenIds.size).toBe(25);
137+
});
138+
139+
it("should reject malformed cursors", async () => {
140+
const res = await request(createApp()).get(
141+
`/api/users/${testAddress}/predictions?cursor=not-a-cursor`
142+
);
143+
144+
expect(res.status).toBe(400);
145+
expect(res.body.error.code).toBe("validation_error");
146+
});
147+
111148
it("should validate address format", async () => {
112149
const res = await request(createApp()).get(
113150
"/api/users/invalid-address/predictions"

0 commit comments

Comments
 (0)