-
-
Notifications
You must be signed in to change notification settings - Fork 377
fix(dashboard): harden keyset cursor parsing and timestamp precision #819
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -161,10 +161,13 @@ interface KeysetCursor { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function parseKeysetCursor(raw: string): KeysetCursor | null { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (raw.length > 1024) return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [HIGH] [TEST-MISSING-CRITICAL] Cursor hardening + keyset pagination changes are not covered by unit tests Why this is a problem: This PR changes pagination behavior (cursor length cap, Suggested fix (extract pure cursor helpers + add fast unit tests): // src/repository/_lib/keyset-cursor.ts
export interface KeysetCursor {
v: string;
id: number;
}
export function parseKeysetCursor(raw: string): KeysetCursor | null {
if (raw.length > 1024) return null;
try {
const parsed = JSON.parse(raw);
if (typeof parsed === "object" && parsed !== null && "v" in parsed && "id" in parsed) {
const id = Number((parsed as { id: unknown }).id);
if (!Number.isFinite(id) || !Number.isInteger(id) || id <= 0) return null;
return { v: String((parsed as { v: unknown }).v), id };
}
} catch {
// not JSON
}
return null;
}// tests/unit/repository/keyset-cursor.test.ts
import { describe, expect, it } from "vitest";
import { parseKeysetCursor } from "@/repository/_lib/keyset-cursor";
describe("parseKeysetCursor", () => {
it("rejects oversize cursors", () => {
expect(parseKeysetCursor("x".repeat(1025))).toBeNull();
});
it("rejects non-positive ids", () => {
expect(parseKeysetCursor(JSON.stringify({ v: "a", id: 0 }))).toBeNull();
});
}); |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const parsed = JSON.parse(raw); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (typeof parsed === "object" && parsed !== null && "v" in parsed && "id" in parsed) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { v: String(parsed.v), id: Number(parsed.id) }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const id = Number(parsed.id); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!Number.isFinite(id) || !Number.isInteger(id) || id <= 0) return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The if (!Number.isInteger(id) || id <= 0) return null; |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { v: String(parsed.v), id }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // not JSON - treat as offset | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -299,18 +302,20 @@ export async function findUserListBatch( | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Keyset cursor: seek past the last row of the previous page. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // ASC: ORDER BY col ASC, id ASC -> WHERE (col, id) > (cv, cid) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // DESC: ORDER BY col DESC, id ASC -> mixed direction, must decompose manually | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Truncate timestamps to millisecond precision to match JS Date encoding. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let offset = 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (cursor && useKeyset) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const keyset = parseKeysetCursor(cursor); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (keyset) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (sortBy === "createdAt") { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const d = new Date(keyset.v); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!Number.isNaN(d.getTime())) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const truncCol = sql`date_trunc('milliseconds', ${sortColumn})`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [HIGH] [LOGIC-BUG] Keyset predicate truncates Why this is a problem: Keyset pagination requires the seek predicate and Suggested fix (use the same const keysetSortColumn =
sortBy === "createdAt" ? sql`date_trunc('milliseconds', ${sortColumn})` : sortColumn;
const orderByClause = sortOrder === "asc" ? asc(keysetSortColumn) : sql`${keysetSortColumn} DESC`;
// ... and reuse keysetSortColumn in the keyset predicate
conditions.push(sql`(${keysetSortColumn}, ${users.id}) > (${d}, ${keyset.id})`); |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (sortOrder === "asc") { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| conditions.push(sql`(${sortColumn}, ${users.id}) > (${d}, ${keyset.id})`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| conditions.push(sql`(${truncCol}, ${users.id}) > (${d}, ${keyset.id})`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| conditions.push( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sql`(${sortColumn} < ${d} OR (${sortColumn} = ${d} AND ${users.id} > ${keyset.id}))` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sql`(${truncCol} < ${d} OR (${truncCol} = ${d} AND ${users.id} > ${keyset.id}))` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
310
to
321
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if
Suggested change
Prompt To Fix With AIThis is a comment left during a code review.
Path: src/repository/user.ts
Line: 310-321
Comment:
if `keyset.v` contains an invalid date string (e.g. corrupted cursor), the `Number.isNaN(d.getTime())` check fails silently and no condition is added. this causes the query to fetch from the beginning instead of the intended page. consider falling back to offset or returning early:
```suggestion
if (sortBy === "createdAt") {
const d = new Date(keyset.v);
if (Number.isNaN(d.getTime())) {
// Invalid date in cursor - fall back to offset
offset = Math.max(Number(cursor) || 0, 0);
} else {
const truncCol = sql`date_trunc('milliseconds', ${sortColumn})`;
if (sortOrder === "asc") {
conditions.push(sql`(${truncCol}, ${users.id}) > (${d}, ${keyset.id})`);
} else {
conditions.push(
sql`(${truncCol} < ${d} OR (${truncCol} = ${d} AND ${users.id} > ${keyset.id}))`
);
}
}
```
How can I resolve this? If you propose a fix, please make it concise. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -323,6 +328,9 @@ export async function findUserListBatch( | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Cursor is not valid keyset JSON -- fall back to offset | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| offset = Math.max(Number(cursor) || 0, 0); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In the const MAX_OFFSET = 10000; // Define a reasonable upper limit for the offset
// Cursor is not valid keyset JSON -- fall back to offset
offset = Math.min(Math.max(Number(cursor) || 0, 0), MAX_OFFSET);
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [HIGH] [LOGIC-BUG] Offset fallback accepts Why this is a problem: Suggested fix: function parseOffsetCursor(raw: string): number {
if (raw.length > 32) return 0;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || !Number.isSafeInteger(parsed) || parsed < 0) return 0;
return parsed;
}
// ...
offset = parseOffsetCursor(cursor); |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else if (cursor) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Offset fallback for nullable columns | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -369,8 +377,11 @@ export async function findUserListBatch( | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (hasMore) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (useKeyset) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const lastRow = usersToReturn[usersToReturn.length - 1]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const rawValue = sortBy === "name" ? lastRow.name : lastRow.createdAt; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| nextCursor = encodeKeysetCursor(rawValue, lastRow.id); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const keysetRowValue: Record<string, unknown> = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name: lastRow.name, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| createdAt: lastRow.createdAt, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| nextCursor = encodeKeysetCursor(keysetRowValue[sortBy], lastRow.id); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| nextCursor = String(offset + effectiveLimit); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Medium] [COMMENT-INACCURATE] Comment does not match function behavior
Why this is a problem: The comment states the function clears all sessions cache including active and inactive, but the function only deletes the 'all_sessions' key. This is misleading and could cause developers to misunderstand the function's scope.
Suggested fix: