fix(dashboard): harden keyset cursor parsing and timestamp precision#819
Conversation
Address review bot comments:
- parseKeysetCursor: add 1024 byte length limit, validate id is finite
positive integer, reject NaN/negative/zero
- Keyset fallback: when cursor is not valid JSON but useKeyset is true,
fall back to offset instead of silently resetting to page 1
- Timestamp precision: use date_trunc('milliseconds', col) in keyset
comparisons to match JS Date encoding precision and prevent duplicate
rows at page boundaries
- Cursor encoding: replace hardcoded name/createdAt ternary with
extensible lookup map
- getUsersUsageBatch: filter negative/zero IDs in addition to non-integers
Summary of ChangesHello @ding113, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request significantly improves the robustness and precision of the dashboard's user pagination system. It addresses several edge cases and potential inconsistencies by hardening cursor parsing, ensuring accurate timestamp comparisons, and refining input validation. These changes lead to a more reliable and predictable user experience when navigating user lists, particularly with keyset pagination. Highlights
Changelog
Activity
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
📝 WalkthroughWalkthrough增强输入验证和光标处理。GetUsersUsageBatch 过滤非正整数 ID;keyset 光标解析添加长度检查和 ID 验证;分页逻辑增加毫秒精度截断和无效光标回退机制。 Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 分钟 Possibly related PRs
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (3)
src/actions/users.ts (1)
781-783: 过滤非正整数 ID — LGTM,但存在一处不一致。添加
id > 0条件与parseKeysetCursor中的 ID 验证保持一致,能避免无效 ID 进入下游批量查询。但同文件中
batchUpdateUsers(第 866 行)的类似逻辑仍仅使用Number.isInteger(id)而未加id > 0检查:const requestedIds = Array.from(new Set(params.userIds)).filter((id) => Number.isInteger(id));虽然零或负 ID 不会匹配到数据库记录(不会造成数据破坏),但建议统一验证逻辑以保持一致性,也能避免不必要的数据库查询。
♻️ 建议统一 ID 过滤逻辑
- const requestedIds = Array.from(new Set(params.userIds)).filter((id) => Number.isInteger(id)); + const requestedIds = Array.from(new Set(params.userIds)).filter( + (id) => Number.isInteger(id) && id > 0 + );🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/actions/users.ts` around lines 781 - 783, The ID filtering is inconsistent: you added id > 0 for sanitizedIds but batchUpdateUsers still only checks Number.isInteger(id); update the requestedIds construction in batchUpdateUsers to mirror the earlier check (dedupe via Array.from(new Set(...)) and filter to Number.isInteger(id) && id > 0) so zero/negative IDs are excluded before any DB queries; reference the requestedIds variable in batchUpdateUsers and ensure the same validation as sanitizedIds.src/repository/user.ts (2)
380-384: cursor 编码使用查找映射 — 可扩展性好,但存在轻微的类型安全隐患。用
Record<string, unknown>加keysetRowValue[sortBy]代替硬编码三元表达式,增加了可扩展性。但sortBy类型是完整的联合类型(含"tags","expiresAt"等),而此映射只包含"name"和"createdAt"两个 key。虽然运行时被useKeyset守卫(只有KEYSET_SORT_COLUMNS中的才进入此分支),但在类型层面keysetRowValue[sortBy]可能为undefined,静默传入encodeKeysetCursor产生""。建议用
satisfies或从KEYSET_SORT_COLUMNS提取类型来收窄:♻️ 可选的类型收窄建议
+type KeysetSortField = "name" | "createdAt"; + if (hasMore) { if (useKeyset) { const lastRow = usersToReturn[usersToReturn.length - 1]; - const keysetRowValue: Record<string, unknown> = { + const keysetRowValue: Record<KeysetSortField, unknown> = { name: lastRow.name, createdAt: lastRow.createdAt, }; - nextCursor = encodeKeysetCursor(keysetRowValue[sortBy], lastRow.id); + nextCursor = encodeKeysetCursor(keysetRowValue[sortBy as KeysetSortField], lastRow.id); } else {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/repository/user.ts` around lines 380 - 384, The mapping keysetRowValue + access keysetRowValue[sortBy] is type-unsafe because sortBy is a wider union than the two keys present; update the code around keysetRowValue and encodeKeysetCursor to use a narrowed type derived from KEYSET_SORT_COLUMNS (or use TypeScript's satisfies with an explicit mapped type) so that keysetRowValue only permits the valid sort keys and key access is non-optional, or alternatively explicitly narrow sortBy (when guarded by useKeyset) before indexing; ensure the object includes the corresponding property for every allowed KEYSET_SORT_COLUMNS entry and pass a properly typed value to encodeKeysetCursor to avoid undefined/empty-string being sent.
310-320:date_trunc('milliseconds', ...)正确解决了精度不匹配问题,但需注意索引影响。用
date_trunc将 PostgreSQL 微秒级的created_at截断至毫秒以匹配 JSDate编码精度,从而避免分页边界的重复行问题——思路正确。但
date_trunc('milliseconds', ${sortColumn})会使idx_users_created_at索引无法直接用于该 WHERE 子句(函数包裹导致无法走 B-tree 索引)。若用户表规模较大,keyset 查询可能退化为顺序扫描。如果未来出现性能瓶颈,可考虑以下方案之一:
- 创建表达式索引
CREATE INDEX ON users (date_trunc('milliseconds', created_at));- 改用范围条件(
col >= truncated_val AND col < truncated_val + interval '1ms')以保留原始索引的可用性。当前用户管理页面的数据量通常在可接受范围内,仅作提醒。
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/repository/user.ts` around lines 310 - 320, The current keyset branch uses date_trunc('milliseconds', ${sortColumn}) which breaks B-tree use of the existing idx_users_created_at index; to fix, either add an expression index for date_trunc('milliseconds', created_at) or avoid the function: compute the truncated JS Date value d and replace the date_trunc comparison in the ${sortBy} === "createdAt" branch with equivalent range predicates (e.g. ${sortColumn} >= ${d} AND ${sortColumn} < (${d} + sql`interval '1ms'`) and tie-break on ${users.id}) so the original index can be used; update the conditions pushed where date_trunc is used to one of these two approaches.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@src/actions/users.ts`:
- Around line 781-783: The ID filtering is inconsistent: you added id > 0 for
sanitizedIds but batchUpdateUsers still only checks Number.isInteger(id); update
the requestedIds construction in batchUpdateUsers to mirror the earlier check
(dedupe via Array.from(new Set(...)) and filter to Number.isInteger(id) && id >
0) so zero/negative IDs are excluded before any DB queries; reference the
requestedIds variable in batchUpdateUsers and ensure the same validation as
sanitizedIds.
In `@src/repository/user.ts`:
- Around line 380-384: The mapping keysetRowValue + access
keysetRowValue[sortBy] is type-unsafe because sortBy is a wider union than the
two keys present; update the code around keysetRowValue and encodeKeysetCursor
to use a narrowed type derived from KEYSET_SORT_COLUMNS (or use TypeScript's
satisfies with an explicit mapped type) so that keysetRowValue only permits the
valid sort keys and key access is non-optional, or alternatively explicitly
narrow sortBy (when guarded by useKeyset) before indexing; ensure the object
includes the corresponding property for every allowed KEYSET_SORT_COLUMNS entry
and pass a properly typed value to encodeKeysetCursor to avoid
undefined/empty-string being sent.
- Around line 310-320: The current keyset branch uses date_trunc('milliseconds',
${sortColumn}) which breaks B-tree use of the existing idx_users_created_at
index; to fix, either add an expression index for date_trunc('milliseconds',
created_at) or avoid the function: compute the truncated JS Date value d and
replace the date_trunc comparison in the ${sortBy} === "createdAt" branch with
equivalent range predicates (e.g. ${sortColumn} >= ${d} AND ${sortColumn} <
(${d} + sql`interval '1ms'`) and tie-break on ${users.id}) so the original index
can be used; update the conditions pushed where date_trunc is used to one of
these two approaches.
ℹ️ Review info
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Cache: Disabled due to Reviews > Disable Cache setting
📒 Files selected for processing (2)
src/actions/users.tssrc/repository/user.ts
There was a problem hiding this comment.
Code Review
This pull request introduces several valuable improvements to the dashboard's user list functionality, including hardening of keyset cursor parsing with size limits and ID validation, and fixing timestamp precision using date_trunc for keyset pagination on createdAt. Input validation for user IDs and refactoring cursor encoding to use a lookup map also enhance maintainability. However, a medium-severity Denial of Service (DoS) vulnerability has been identified: the fallback mechanism for keyset cursor pagination treats invalid cursors as numeric offsets without upper-bound limits, allowing an authenticated administrator to cause excessive database load. A suggestion has also been made to simplify cursor parsing logic by removing a redundant check.
| } | ||
| } else { | ||
| // Cursor is not valid keyset JSON -- fall back to offset | ||
| offset = Math.max(Number(cursor) || 0, 0); |
There was a problem hiding this comment.
In the findUserListBatch function, when a cursor is provided but is not a valid keyset JSON, the code falls back to treating the cursor as a numeric offset. The line offset = Math.max(Number(cursor) || 0, 0); converts the user-provided cursor string to a number without any upper-bound validation. An authenticated administrator could exploit this by providing a very large number (e.g., 9999999999) as the cursor, which would be passed directly to the database query's OFFSET clause. A large offset can cause severe performance degradation or a denial of service by forcing the database to scan and discard a massive number of rows.
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);| 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; |
There was a problem hiding this comment.
The !Number.isFinite(id) check is redundant here. The Number.isInteger() method returns false for non-finite values such as Infinity, -Infinity, and NaN. Therefore, !Number.isInteger(id) already covers the check for finiteness, making the explicit !Number.isFinite(id) check unnecessary. Removing it simplifies the condition.
if (!Number.isInteger(id) || id <= 0) return null;…sionCache - clearAllSessionsCache -> clearAllSessionsQueryCache (deletes "all_sessions" key only) - clearAllSessionCache -> clearAllCaches (clears both cache instances entirely) Update all call sites and test references.
| if (sortBy === "createdAt") { | ||
| const d = new Date(keyset.v); | ||
| if (!Number.isNaN(d.getTime())) { | ||
| const truncCol = sql`date_trunc('milliseconds', ${sortColumn})`; | ||
| 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}))` | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this 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:
| if (sortBy === "createdAt") { | |
| const d = new Date(keyset.v); | |
| if (!Number.isNaN(d.getTime())) { | |
| const truncCol = sql`date_trunc('milliseconds', ${sortColumn})`; | |
| 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}))` | |
| ); | |
| } | |
| } | |
| 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}))` | |
| ); | |
| } | |
| } |
Prompt To Fix With AI
This 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.| @@ -183,7 +183,7 @@ export function clearActiveSessionsCache() { | |||
| /** | |||
| * 清空所有 Sessions 的缓存(包括活跃和非活跃) | |||
There was a problem hiding this comment.
[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:
/**
* 清空所有 Sessions 查询缓存
*/
export function clearAllSessionsQueryCache() {
activeSessionsCache.delete("all_sessions");
}There was a problem hiding this comment.
Code Review Summary
This PR hardens keyset cursor pagination and renames ambiguous cache functions. The changes are defensive improvements that add validation, handle edge cases gracefully, and fix timestamp precision issues. The refactoring is safe and maintains backward compatibility.
PR Size: XS
- Lines changed: 55 (34 additions, 21 deletions)
- Files changed: 6
Issues Found
| Category | Critical | High | Medium | Low |
|---|---|---|---|---|
| Logic/Bugs | 0 | 0 | 0 | 0 |
| Security | 0 | 0 | 0 | 0 |
| Error Handling | 0 | 0 | 0 | 0 |
| Types | 0 | 0 | 0 | 0 |
| Comments/Docs | 0 | 0 | 1 | 0 |
| Tests | 0 | 0 | 0 | 0 |
| Simplification | 0 | 0 | 0 | 0 |
Medium Priority Issues (Should Fix)
[COMMENT-INACCURATE] src/lib/cache/session-cache.ts:184
- The comment "清空所有 Sessions 的缓存(包括活跃和非活跃)" does not match the function behavior
- The function only deletes the "all_sessions" key, not all active and inactive sessions
- Suggested fix: Update comment to "清空所有 Sessions 查询缓存"
Positive Findings
-
Cursor validation is robust: The 1024-byte limit prevents DoS, and the finite positive integer validation for cursor IDs prevents malformed input issues.
-
Timestamp precision fix is correct: Using
date_trunc('milliseconds', ...)properly aligns PostgreSQL microsecond precision with JavaScript Date millisecond encoding, preventing duplicate rows at page boundaries. -
Fallback handling is graceful: When keyset cursor parsing fails, the code correctly falls back to offset pagination instead of silently resetting to page 1.
-
Function renames improve clarity: The new names
clearAllSessionsQueryCacheandclearAllCachesare more descriptive than the previous ambiguous names. -
Input validation is thorough: The addition of
id > 0validation ingetUsersUsageBatchproperly filters out invalid user IDs.
Review Coverage
- Logic and correctness - Clean
- Security (OWASP Top 10) - Clean
- Error handling - Clean
- Type safety - Clean
- Documentation accuracy - 1 minor issue
- Test coverage - Adequate (test mocks updated)
- Code clarity - Good
Automated review by Claude AI
| } | ||
| } else { | ||
| // Cursor is not valid keyset JSON -- fall back to offset | ||
| offset = Math.max(Number(cursor) || 0, 0); |
There was a problem hiding this comment.
[HIGH] [LOGIC-BUG] Offset fallback accepts Infinity/non-integers and can break the query
Why this is a problem: Number(cursor) accepts scientific notation and can return Infinity (e.g. cursor="1e309") or non-integers. That value then flows into query.offset(offset), which can generate invalid SQL/params and crash pagination for malformed cursors.
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);| if (sortBy === "createdAt") { | ||
| const d = new Date(keyset.v); | ||
| if (!Number.isNaN(d.getTime())) { | ||
| const truncCol = sql`date_trunc('milliseconds', ${sortColumn})`; |
There was a problem hiding this comment.
[HIGH] [LOGIC-BUG] Keyset predicate truncates createdAt but ORDER BY still uses microsecond precision
Why this is a problem: Keyset pagination requires the seek predicate and ORDER BY to use the same sort keys. Here the predicate switches to date_trunc('milliseconds', created_at) (millisecond buckets) while orderByClause remains users.createdAt (microseconds). If multiple rows share the same millisecond but differ in microseconds, the query can return rows in an order the cursor cannot represent, which can lead to skipped rows across page boundaries.
Suggested fix (use the same truncCol in both the predicate and ORDER BY when sortBy === "createdAt"):
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})`);| } | ||
|
|
||
| function parseKeysetCursor(raw: string): KeysetCursor | null { | ||
| if (raw.length > 1024) return null; |
There was a problem hiding this comment.
[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, id validation, createdAt truncation, keyset-to-offset fallback) but adds no tests. This violates the repo guideline: CLAUDE.md: "Test Coverage - All new features must have unit test coverage of at least 80%".
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();
});
});There was a problem hiding this comment.
Code Review Summary
This PR changes cursor parsing and keyset pagination for the users list by adding cursor hard limits, stricter id validation, millisecond truncation for createdAt, and keyset-to-offset fallback. There are correctness risks around offset cursor parsing and createdAt keyset ordering, and the new behavior is not covered by unit tests.
PR Size: XS
- Lines changed: 25
- Files changed: 2
Issues Found
| Category | Critical | High | Medium | Low |
|---|---|---|---|---|
| Logic/Bugs | 0 | 2 | 0 | 0 |
| Security | 0 | 0 | 0 | 0 |
| Error Handling | 0 | 0 | 0 | 0 |
| Types | 0 | 0 | 0 | 0 |
| Comments/Docs | 0 | 0 | 0 | 0 |
| Tests | 0 | 1 | 0 | 0 |
| Simplification | 0 | 0 | 0 | 0 |
Critical Issues (Must Fix)
None.
High Priority Issues (Should Fix)
src/repository/user.ts:333:offset = Math.max(Number(cursor) || 0, 0);can produceInfinity/non-integers; validate the offset cursor before calling.offset().src/repository/user.ts:313: keyset predicate truncatescreatedAtto milliseconds whileORDER BYstill uses microsecond precision; alignORDER BYwith the keyset predicate to avoid skipped rows across pages.src/repository/user.ts:164: cursor hardening + pagination behavior changes landed without tests; add unit coverage perCLAUDE.md: "Test Coverage - All new features must have unit test coverage of at least 80%".
Review Coverage
- Logic and correctness
- Security (OWASP Top 10)
- Error handling
- Type safety
- Documentation accuracy
- Test coverage
- Code clarity
Automated review by Codex AI
There was a problem hiding this comment.
- Reviewed PR
#819and gathered diff/metadata (25lines changed across2files) - Applied PR label:
size/XS - Posted 3 inline review comments (all on diff lines) in
src/repository/user.ts:src/repository/user.ts:333[HIGH] [LOGIC-BUG] offset fallback can yieldInfinity/non-integers (Number(cursor)) and break.offset()src/repository/user.ts:313[HIGH] [LOGIC-BUG]createdAtkeyset predicate usesdate_trunc(...)butORDER BYstill uses microsecond precision (can skip rows)src/repository/user.ts:164[HIGH] [TEST-MISSING-CRITICAL] cursor hardening/pagination changes landed without unit tests (perCLAUDE.mdcoverage rule)
- Submitted the required “Code Review Summary” via
gh pr review --comment
Summary
Follow-up to #818. Addresses review comments that were pushed after the squash merge to harden the keyset cursor pagination implementation on the users page.
Related Issues/PRs
Problem
The keyset cursor pagination introduced in #818 had several edge cases that needed hardening:
idvaluesgetUsersUsageBatchdidn't filter non-positive user IDsSolution
Core Changes
Cursor parsing hardening (
src/repository/user.ts):idas finite positive integer (reject NaN/negative/zero)Timestamp precision fix (
src/repository/user.ts):date_trunc('milliseconds', ...)to align PostgreSQL microsecond timestamps with JS Date millisecond encodingInput validation (
src/actions/users.ts):id <= 0ingetUsersUsageBatchsanitization (in addition to existing non-integer filter)Cursor encoding refactor (
src/repository/user.ts):name/createdAtternary with extensible lookup mapFiles Changed
src/repository/user.tssrc/actions/users.tsid > 0validation togetUsersUsageBatchTesting
Automated Tests
bun run typecheck- PASSbun run test- PASSManual Testing
/dashboard/userscreatedAt(DESC and ASC)namestill worksChecklist
Description enhanced by Claude AI
Greptile Summary
Hardens keyset cursor pagination from #818 with defensive validation and precision fixes. Adds DoS prevention (1024-byte cursor limit), validates cursor IDs as positive integers, and truncates PostgreSQL timestamps to millisecond precision to match JavaScript Date encoding and prevent duplicate rows at page boundaries. Also includes cache function renaming for clarity (
clearAllSessionsCache→clearAllSessionsQueryCache).Key improvements:
Number.isFinite()andid > 0checks reject NaN/Infinity/negative/zero IDsdate_trunc('milliseconds', ...)aligns PostgreSQL microsecond timestamps with JS millisecond precisiongetUsersUsageBatchIssue found:
Confidence Score: 3/5
src/repository/user.ts- the invalid date handling in keyset cursor parsing should be fixed before mergeImportant Files Changed
clearAllSessionsCache→clearAllSessionsQueryCache,clearAllSessionCache→clearAllCachesLast reviewed commit: 8218507