Skip to content

perf(dashboard): lazy-load usage data on users page for instant first render#818

Merged
ding113 merged 2 commits into
devfrom
perf/users-page-lazy-load-usage
Feb 23, 2026
Merged

perf(dashboard): lazy-load usage data on users page for instant first render#818
ding113 merged 2 commits into
devfrom
perf/users-page-lazy-load-usage

Conversation

@ding113

@ding113 ding113 commented Feb 23, 2026

Copy link
Copy Markdown
Owner

Summary

  • Split getUsersBatch into two phases: getUsersBatchCore (users + keys only, 2 fast queries) and getUsersUsageBatch (usage/statistics, lazy-loaded in background)
  • Hybrid cursor pagination: keyset seek for NOT NULL columns (name, createdAt), OFFSET fallback for nullable columns
  • Eliminated redundant findKeyListBatch call via new findKeysStatisticsBatchFromKeys function
  • Added input validation: searchUsersForFilter bounded to 200 results, page limit clamped to [1, 200], getUsersUsageBatch validates batch size (max 500)

Before

  • 7 SQL queries block initial render, including 4 heavy usage_ledger aggregates (14-17s)
  • OFFSET pagination degrades at high page numbers

After

  • 2 fast queries (users + keys) render the table immediately
  • Usage data loads in background per-page via useQueries
  • Keyset cursor for name/createdAt sort avoids sequential scan at high offsets

Related Issues

Files Changed

File Changes
src/repository/key.ts Extract findKeysStatisticsBatchFromKeys + shared _findKeysStatisticsBatchInternal
src/repository/user.ts Hybrid cursor pagination, .limit(200) on search, limit clamping
src/actions/users.ts getUsersBatchCore, getUsersUsageBatch, KeyUsageData type, batch validation
src/app/[locale]/dashboard/users/users-page-client.tsx Two-phase loading with useQueries, usage merge memo
tests/unit/user-repository-search-users-for-filter.test.ts Fix mock chain for .limit()

Test Plan

  • bun run typecheck - no type errors
  • bun run build - production build succeeds
  • bun run lint - clean (only pre-existing warning)
  • bun run test - 3240/3240 passed
  • Manual: open /dashboard/users, verify table renders immediately with user/key data
  • Manual: verify usage columns populate shortly after initial render
  • Manual: verify search/filter works without waiting for usage data
  • Manual: verify expanded row shows correct usage data after lazy load
  • Manual: verify DESC sort pagination returns correct (non-duplicate) results

Description enhanced by Claude AI

Greptile Summary

Optimized dashboard users page with two-phase data loading: getUsersBatchCore immediately returns users + keys (2 fast queries), while getUsersUsageBatch lazy-loads usage/statistics in the background via useQueries. Hybrid pagination now uses keyset cursors for name/createdAt sorts (O(1) seek) and falls back to OFFSET for nullable columns.

Key improvements:

  • Initial table render no longer blocked by 4 heavy usage_ledger aggregates (14-17s → instant)
  • Eliminated redundant findKeyListBatch in old code via new findKeysStatisticsBatchFromKeys helper
  • Added input validation: search capped at 200 results, page limit clamped to [1, 200], usage batch max 500
  • Frontend uses stable dataUpdatedAt fingerprint to prevent memo thrashing

Issues found:

  • Keyset cursor encoding on line 372 hardcodes name check, assumes all other keyset columns are createdAt — breaks extensibility
  • Cursor validation incomplete: malformed date strings or special characters not sanitized before SQL comparison
  • getUsers() and old getUsersBatch() changed to sequential await instead of parallel Promise.all for statistics fetch — degrades performance benefit of the new helper
  • Frontend pageUserIds memo creates new array reference on every pagination, forcing useQueries to regenerate descriptors
  • User merge logic clones entire user object even when only some keys have usage data

Confidence Score: 3/5

  • Safe to merge with minor keyset cursor edge cases and performance quirks in legacy code paths
  • The core lazy-loading architecture is sound and addresses the stated performance problem. However, keyset cursor encoding assumes only two possible columns (line 372 hardcoded ternary), cursor validation is incomplete (no type checking for parsed values), and the sequential query pattern in getUsers()/getUsersBatch() undermines the benefit of the new batch helper. These issues are non-critical but reduce robustness and performance in edge cases.
  • Pay close attention to src/repository/user.ts (keyset cursor logic) and verify DESC sort pagination manually per test plan

Important Files Changed

Filename Overview
src/repository/user.ts Added hybrid keyset/offset pagination with 200-row limit on search. Keyset cursor encoding hardcoded for name/createdAt; cursor validation incomplete.
src/actions/users.ts Split getUsersBatch into fast core (users+keys) and lazy usage load. Sequential query chain in getUsers degrades parallel fetch benefit. Input validation added for batch size.
src/app/[locale]/dashboard/users/users-page-client.tsx Two-phase loading with useQueries for per-page usage. Stable fingerprint via dataUpdatedAt prevents memo thrash. Minor inefficiency in user merge logic.
src/repository/key.ts Extracted findKeysStatisticsBatchFromKeys to eliminate redundant findKeyListBatch when caller already has keys. Clean refactor with shared internal function.
tests/unit/user-repository-search-users-for-filter.test.ts Fixed mock chain to include .limit() call added to searchUsersForFilter. Test correctly validates ILIKE pattern trimming.

Sequence Diagram

sequenceDiagram
    participant Client as Users Page Client
    participant Core as getUsersBatchCore
    participant Repo as findUserListBatch
    participant Usage as getUsersUsageBatch
    participant DB as Database

    Client->>Core: Request page (cursor, limit)
    Core->>Repo: findUserListBatch(filters)
    
    alt Keyset cursor (name/createdAt)
        Repo->>DB: SELECT users WHERE (col, id) > (cursor.v, cursor.id)
    else Offset cursor (nullable columns)
        Repo->>DB: SELECT users OFFSET cursor LIMIT
    end
    
    DB-->>Repo: Users batch + nextCursor
    Repo-->>Core: {users, nextCursor, hasMore}
    
    Core->>DB: findKeyListBatch(userIds)
    DB-->>Core: keysMap
    
    Core-->>Client: {users with keys, usage=0}
    
    Note over Client: Render table instantly
    
    Client->>Usage: getUsersUsageBatch(userIds) [background]
    Usage->>DB: findKeyListBatch(userIds)
    DB-->>Usage: keysMap
    
    Usage->>DB: findKeyUsageTodayBatch(userIds)
    DB-->>Usage: usageMap
    
    Usage->>DB: findKeysStatisticsBatchFromKeys(keysMap)
    DB-->>Usage: statisticsMap
    
    Usage-->>Client: {usageByKeyId}
    
    Note over Client: Merge usage into displayed users
Loading

Last reviewed commit: a5a49d1

… render

Split getUsersBatch into two phases:
- getUsersBatchCore: returns users + keys only (2 queries, renders immediately)
- getUsersUsageBatch: returns usage/statistics per key (lazy-loaded in background)

Repository optimizations:
- Add findKeysStatisticsBatchFromKeys to eliminate redundant findKeyListBatch call
- Hybrid cursor pagination (keyset for NOT NULL columns, offset fallback for nullable)
- Bound searchUsersForFilter to 200 results
- Clamp page limit to [1, 200], validate userIds batch size (max 500)

Client changes:
- useInfiniteQuery calls getUsersBatchCore for instant table render
- useQueries fires per-page usage queries independently
- Merge usage data into allUsers memo as it arrives

Reduces initial page load from 7 slow queries to 2 fast queries.
@coderabbitai

coderabbitai Bot commented Feb 23, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

将用户列表分页从数值光标改为字符串并引入键集/偏移混合分页;将密钥统计批处理函数拆分并重命名,新增按需加载使用数据的核心与使用API;客户端改为先拉取核心用户页,再并行延迟加载每页键使用统计;相关测试与仓库查询逻辑同步更新。

Changes

Cohort / File(s) Summary
统计与核心 API 重构
src/actions/users.ts, src/repository/key.ts
新增 findKeysStatisticsBatchFromKeys,将原 findKeysWithStatisticsBatch 委托至共享内部实现;在 actions 中新增 getUsersBatchCoregetUsersUsageBatch,并引入 KeyUsageData / GetUsersUsageBatchResult 类型以支持按键使用数据的延迟加载。
分页基础设施与查询
src/repository/user.ts
将批量列表接口的光标从 number 改为 string;实现混合分页(键集 cursor 与偏移回退),增加 parseKeysetCursor / encodeKeysetCursor 等辅助,结果上限为 200 条,nextCursor 返回 `string
客户端并行使用数据集成
src/app/[locale]/dashboard/users/users-page-client.tsx
管理员路径改为使用 getUsersBatchCore 获取核心用户/密钥数据,使用 useQueries 并行拉取每页的 getUsersUsageBatch,将 usageByKeyId 合并到每个用户的 keys 上(todayUsage、todayCallCount、todayTokens、lastUsedAt、lastProviderName、modelStats),并在刷新时失效 users-usage 缓存。
测试适配查询构建器链式 API
tests/unit/user-repository-search-users-for-filter.test.ts
调整对 "@/drizzle/db" 的 mock:orderBy 现在返回可链式调用的对象(.limit(...)),以匹配新的查询构建器行为。
清单文件变更
package.json
清单有小幅变更(行数 +26/-0),可能为依赖或脚本调整。

预计代码审查工作量

🎯 4 (复杂) | ⏱️ ~45 分钟

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 53.85% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed 标题清晰准确地概括了PR的主要改进:通过分阶段加载使用数据,实现用户仪表板页面的即时首次渲染。
Description check ✅ Passed 描述详细关联了所有变更,包括前后对比、相关问题、文件清单和测试计划,充分解释了PR的目的和实现。

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch perf/users-page-lazy-load-usage

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello @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 enhances the performance of the user dashboard by refactoring data loading mechanisms. It introduces a two-phase approach where core user and key information renders immediately, while heavier usage statistics are fetched and displayed asynchronously. This change also upgrades the pagination strategy to a more efficient hybrid cursor-based method, ensuring consistent performance regardless of the dataset size.

Highlights

  • Performance Improvement: Implemented lazy-loading for user usage data on the dashboard to achieve instant initial render.
  • Two-Phase Data Fetching: Split user data retrieval into getUsersBatchCore (fast, essential user/key data) and getUsersUsageBatch (background-loaded usage statistics).
  • Hybrid Cursor Pagination: Introduced keyset pagination for non-nullable sort columns (name, createdAt) and retained offset pagination as a fallback for nullable columns, improving performance at high page numbers.
  • Optimized Key Statistics Retrieval: Refactored key statistics fetching to reuse existing key data, eliminating a redundant database call.
  • Input Validation: Added limits and validation for search results, page limits, and batch sizes to prevent excessive queries.
Changelog
  • src/actions/users.ts
    • Refactored getUsersBatch to use the new findKeysStatisticsBatchFromKeys.
    • Introduced getUsersBatchCore for fetching core user and key data.
    • Added getUsersUsageBatch for lazy-loading usage statistics.
    • Defined KeyUsageData interface for usage statistics.
    • Updated GetUsersBatchParams and GetUsersBatchResult to use string cursors.
    • Added batch size validation to getUsersUsageBatch.
  • src/app/[locale]/dashboard/users/users-page-client.tsx
    • Updated imports to use getUsersBatchCore and getUsersUsageBatch.
    • Implemented useQueries to fetch usage data for each page independently in the background.
    • Added memoization logic to merge lazy-loaded usage data with core user data.
    • Modified useInfiniteQuery to use getUsersBatchCore and string cursors.
    • Invalidated users-usage query cache on refresh.
  • src/repository/key.ts
    • Extracted shared logic into _findKeysStatisticsBatchInternal.
    • Created findKeysStatisticsBatchFromKeys to utilize pre-fetched key maps, avoiding redundant findKeyListBatch calls.
  • src/repository/user.ts
    • Implemented hybrid cursor pagination logic within findUserListBatch.
    • Defined KEYSET_SORT_COLUMNS, parseKeysetCursor, and encodeKeysetCursor for keyset pagination.
    • Clamped the limit parameter in findUserListBatch to a maximum of 200.
    • Added a limit(200) clause to searchUsersForFilter to restrict results.
    • Updated UserListBatchFilters and UserListBatchResult to use string cursors.
  • tests/unit/user-repository-search-users-for-filter.test.ts
    • Adjusted the drizzle-orm mock chain to include a .limit() method, matching the updated searchUsersForFilter implementation.
Activity
  • No specific activity (comments, reviews, etc.) has been recorded for this pull request yet.
Using Gemini Code Assist

The 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 /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

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 .gemini/ folder in the base of the repository. Detailed instructions can be found here.

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

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@github-actions github-actions Bot added size/M Medium PR (< 500 lines) enhancement New feature or request area:UI area:statistics labels Feb 23, 2026

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

}
}
return merged;
}, [usageQueries]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

usageQueries array reference changes on every render. Memo should depend on query states instead.

Suggested change
}, [usageQueries]);
}, [usageQueries.map(q => q.data).join()]);
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/[locale]/dashboard/users/users-page-client.tsx
Line: 228

Comment:
`usageQueries` array reference changes on every render. Memo should depend on query states instead.

```suggestion
  }, [usageQueries.map(q => q.data).join()]);
```

How can I resolve this? If you propose a fix, please make it concise.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/unit/user-repository-search-users-for-filter.test.ts (1)

40-46: ⚠️ Potential issue | 🟡 Minor

测试名称 "returns all users without limit" 与当前实现不一致。

searchUsersForFilter 现在始终应用 .limit(200),但测试名称仍暗示无限制返回。此外,未验证 limitMock 是否被调用以及调用参数(200)是否正确。

建议更新测试名称并增加断言
-  test("returns all users without limit", async () => {
+  test("returns users capped at 200", async () => {
     const { searchUsersForFilter } = await import("@/repository/user");
 
     const result = await searchUsersForFilter();
 
     expect(result).toEqual(resolvedRows);
+    expect(limitMock).toHaveBeenCalledWith(200);
   });

注意:limitMock 需要从 mock 作用域中导出或提升为模块级变量以便在测试中访问。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/user-repository-search-users-for-filter.test.ts` around lines 40 -
46, Rename the test "returns all users without limit" to reflect that
searchUsersForFilter applies a limit (e.g., "applies default limit when no limit
provided") and add assertions that the mock limit function (limitMock) is called
and called with 200; update the test to import searchUsersForFilter and assert
expect(limitMock).toHaveBeenCalled() and
expect(limitMock).toHaveBeenCalledWith(200). If limitMock is currently scoped
inside the mock definition, lift it to module scope or export it from the mock
so the test can access it.
🧹 Nitpick comments (4)
src/repository/user.ts (1)

295-297: DESC 排序使用原始 SQL 而非 Drizzle 的 desc() 函数。

ASC 使用了 Drizzle 的 asc(sortColumn),但 DESC 使用了 sql`${sortColumn} DESC`。建议保持一致性,导入并使用 desc 函数。

建议统一使用 Drizzle 排序函数
-import { and, asc, eq, isNull, type SQL, sql } from "drizzle-orm";
+import { and, asc, desc, eq, isNull, type SQL, sql } from "drizzle-orm";
-  const orderByClause = sortOrder === "asc" ? asc(sortColumn) : sql`${sortColumn} DESC`;
+  const orderByClause = sortOrder === "asc" ? asc(sortColumn) : desc(sortColumn);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/repository/user.ts` around lines 295 - 297, The code mixes Drizzle
helpers and raw SQL for ordering: asc(sortColumn) is used but DESC is produced
via sql`${sortColumn} DESC`; update the orderByClause to use Drizzle's desc()
for consistency by importing desc from Drizzle and replacing the raw SQL branch
so orderByClause uses asc(sortColumn) when sortOrder === "asc" and
desc(sortColumn) when sortOrder === "desc" (referencing sortOrder, sortColumn,
asc, desc, and orderByClause to locate the change).
src/app/[locale]/dashboard/users/users-page-client.tsx (1)

205-228: usageByKeyIduseMemo 依赖 [usageQueries],但 useQueries 每次渲染返回新数组引用,导致 memo 每次都重新计算。

代码注释声称"只在实际数据变化时产生新对象",但实际上 usageQueries 是一个不稳定的引用。虽然 Object.assign 本身开销不大,但每次产生的新 usageByKeyId 对象会进一步导致 allUsers memo 也在每次渲染时重新计算,对大量用户场景下有不必要的开销。

一种常见的解决方式是基于实际数据内容来推导稳定的依赖:

建议使用稳定的依赖值
+ // Derive a stable fingerprint from the actual query data
+ const usageDataFingerprint = useMemo(() => {
+   return usageQueries
+     .map((q) => (q.data?.ok ? q.dataUpdatedAt : 0))
+     .join(",");
+ }, [usageQueries]);
+
  const usageByKeyId = useMemo(() => {
    const merged: Record<number, KeyUsageData> = {};
    for (const query of usageQueries) {
      if (query.data?.ok) {
        Object.assign(merged, query.data.data.usageByKeyId);
      }
    }
    return merged;
-  }, [usageQueries]);
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [usageDataFingerprint]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`[locale]/dashboard/users/users-page-client.tsx around lines 205 -
228, The memo for usageByKeyId depends on the unstable usageQueries array
reference from useQueries, causing unnecessary recomputation; change the useMemo
dependency to a stable derived value from the queries' actual results (for
example compute deps = usageQueries.map(q => q.data ? q.data : null) or
usageQueries.map(q => q.data?.data?.usageByKeyId ?? null) and use [deps] as the
dependency) and keep the existing merging logic inside the useMemo (referencing
usageByKeyId, usageQueries, useMemo, and getUsersUsageBatch) so usageByKeyId
only recalculates when the contained query data actually changes.
src/actions/users.ts (2)

630-747: getUsersBatchCoregetUsersBatchUserDisplay 构建逻辑高度重复。

两个函数中 user → UserDisplay 的映射代码几乎完全相同(约 60 行),仅 usage 字段的来源不同。建议提取为共享的辅助函数,接受可选的 usage/statistics lookup 参数,以减少未来维护时两处不同步的风险。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/actions/users.ts` around lines 630 - 747, getUsersBatchCore and
getUsersBatch duplicate the user → UserDisplay mapping; extract that logic into
a single helper (e.g., buildUserDisplay or mapUserToDisplay) that accepts a User
object, its keys (from keysMap), locale/t translations, and an optional
usage/statistics lookup (usageMap or a callback) so getUsersBatchCore can pass
defaults and getUsersBatch can pass real usage; update getUsersBatchCore and
getUsersBatch to call this helper (keeping symbols: getUsersBatchCore,
getUsersBatch, UserDisplay, keysMap) to eliminate the ~60-line duplication while
preserving all existing fields and formatting behavior.

793-798: 依赖顺序正确,但需注意 inArray(usageLedger.key, keyStrings) 的查询性能

Promise.all 并行获取 keysMapusageMap,然后 findKeysStatisticsBatchFromKeys(keysMap) 串行执行,这是正确的依赖顺序。总计 5 个 DB 查询(findKeyListBatch + findKeyUsageTodayBatch + 统计中的 3 个:today call counts、last usage LATERAL、model stats)。

usageMapstatisticsMap 数据不重复——usageMap 汇总每个 key 的总成本/token,statisticsMap 提供每个 model 的详细分解。

需注意 _findKeysStatisticsBatchInternalinArray(usageLedger.key, keyStrings) 的查询性能。当 sanitizedIds 接近 500 且每个用户有多个 key 时,keyStrings 列表会很大。虽然存在 idx_usage_ledger_key_created_at 复合索引可帮助优化,但在极端情况下仍应监控查询性能表现。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/actions/users.ts` around lines 793 - 798, The DB batching is correct but
the _findKeysStatisticsBatchInternal query uses inArray(usageLedger.key,
keyStrings) which can produce a very large IN list and hurt performance when
sanitizedIds (and thus keyStrings) is large; replace or mitigate the large
IN-list by paginating/chunking keyStrings or switching to a join-based approach
(e.g., temporary table, CTE, or EXISTS/INNER JOIN against a values/table of
keys) inside findKeysStatisticsBatchFromKeys/_findKeysStatisticsBatchInternal,
and ensure indexes like idx_usage_ledger_key_created_at are still used; update
findKeyListBatch, findKeyUsageTodayBatch and findKeysStatisticsBatchFromKeys
call sites only if needed to support the new join/temporary-key-table strategy.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/repository/user.ts`:
- Around line 163-173: The parseKeysetCursor function currently casts parsed.id
with Number(parsed.id) which can yield NaN and later produce invalid SQL; update
parseKeysetCursor to validate that parsed.id is a finite integer before
returning: after JSON.parse ensure "v" exists and is a stringable value and that
Number(parsed.id) yields a finite integer (use Number(parsed.id) into a variable
like idNum and check Number.isInteger(idNum) && Number.isFinite(idNum)); if
validation fails return null instead of returning {v, id}; keep returning { v:
String(parsed.v), id: idNum } when valid.

---

Outside diff comments:
In `@tests/unit/user-repository-search-users-for-filter.test.ts`:
- Around line 40-46: Rename the test "returns all users without limit" to
reflect that searchUsersForFilter applies a limit (e.g., "applies default limit
when no limit provided") and add assertions that the mock limit function
(limitMock) is called and called with 200; update the test to import
searchUsersForFilter and assert expect(limitMock).toHaveBeenCalled() and
expect(limitMock).toHaveBeenCalledWith(200). If limitMock is currently scoped
inside the mock definition, lift it to module scope or export it from the mock
so the test can access it.

---

Nitpick comments:
In `@src/actions/users.ts`:
- Around line 630-747: getUsersBatchCore and getUsersBatch duplicate the user →
UserDisplay mapping; extract that logic into a single helper (e.g.,
buildUserDisplay or mapUserToDisplay) that accepts a User object, its keys (from
keysMap), locale/t translations, and an optional usage/statistics lookup
(usageMap or a callback) so getUsersBatchCore can pass defaults and
getUsersBatch can pass real usage; update getUsersBatchCore and getUsersBatch to
call this helper (keeping symbols: getUsersBatchCore, getUsersBatch,
UserDisplay, keysMap) to eliminate the ~60-line duplication while preserving all
existing fields and formatting behavior.
- Around line 793-798: The DB batching is correct but the
_findKeysStatisticsBatchInternal query uses inArray(usageLedger.key, keyStrings)
which can produce a very large IN list and hurt performance when sanitizedIds
(and thus keyStrings) is large; replace or mitigate the large IN-list by
paginating/chunking keyStrings or switching to a join-based approach (e.g.,
temporary table, CTE, or EXISTS/INNER JOIN against a values/table of keys)
inside findKeysStatisticsBatchFromKeys/_findKeysStatisticsBatchInternal, and
ensure indexes like idx_usage_ledger_key_created_at are still used; update
findKeyListBatch, findKeyUsageTodayBatch and findKeysStatisticsBatchFromKeys
call sites only if needed to support the new join/temporary-key-table strategy.

In `@src/app/`[locale]/dashboard/users/users-page-client.tsx:
- Around line 205-228: The memo for usageByKeyId depends on the unstable
usageQueries array reference from useQueries, causing unnecessary recomputation;
change the useMemo dependency to a stable derived value from the queries' actual
results (for example compute deps = usageQueries.map(q => q.data ? q.data :
null) or usageQueries.map(q => q.data?.data?.usageByKeyId ?? null) and use
[deps] as the dependency) and keep the existing merging logic inside the useMemo
(referencing usageByKeyId, usageQueries, useMemo, and getUsersUsageBatch) so
usageByKeyId only recalculates when the contained query data actually changes.

In `@src/repository/user.ts`:
- Around line 295-297: The code mixes Drizzle helpers and raw SQL for ordering:
asc(sortColumn) is used but DESC is produced via sql`${sortColumn} DESC`; update
the orderByClause to use Drizzle's desc() for consistency by importing desc from
Drizzle and replacing the raw SQL branch so orderByClause uses asc(sortColumn)
when sortOrder === "asc" and desc(sortColumn) when sortOrder === "desc"
(referencing sortOrder, sortColumn, asc, desc, and orderByClause to locate the
change).

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Cache: Disabled due to Reviews > Disable Cache setting

📥 Commits

Reviewing files that changed from the base of the PR and between 6ab889d and 3d1e53c.

📒 Files selected for processing (5)
  • src/actions/users.ts
  • src/app/[locale]/dashboard/users/users-page-client.tsx
  • src/repository/key.ts
  • src/repository/user.ts
  • tests/unit/user-repository-search-users-for-filter.test.ts

Comment thread src/repository/user.ts
Comment on lines +163 to +173
function parseKeysetCursor(raw: string): KeysetCursor | null {
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) };
}
} catch {
// not JSON - treat as offset
}
return null;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

parseKeysetCursor 未校验 id 是否为有效整数,可能导致 SQL 异常。

Number(parsed.id) 在输入非数值时会返回 NaN,后续 seek 条件(如 (col, id) > (d, NaN))可能生成无效 SQL 或产生不可预期的查询结果。

建议增加 id 有效性校验
 function parseKeysetCursor(raw: string): KeysetCursor | null {
   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)) return null;
+      return { v: String(parsed.v), id };
     }
   } catch {
     // not JSON - treat as offset
   }
   return null;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/repository/user.ts` around lines 163 - 173, The parseKeysetCursor
function currently casts parsed.id with Number(parsed.id) which can yield NaN
and later produce invalid SQL; update parseKeysetCursor to validate that
parsed.id is a finite integer before returning: after JSON.parse ensure "v"
exists and is a stringable value and that Number(parsed.id) yields a finite
integer (use Number(parsed.id) into a variable like idNum and check
Number.isInteger(idNum) && Number.isFinite(idNum)); if validation fails return
null instead of returning {v, id}; keep returning { v: String(parsed.v), id:
idNum } when valid.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request significantly optimizes the users dashboard by implementing lazy-loading for usage and statistics data, reducing initial page render time through a two-phase fetching mechanism. It also introduces hybrid cursor pagination for efficient handling of large datasets. However, the new cursor parsing logic has a medium-severity Denial of Service (DoS) vulnerability due to a lack of input size validation on the pagination cursor, which could allow a malicious user to crash the server. Additionally, there are issues with the keyset pagination logic, including a fallback bug when switching sort orders and potential data consistency issues from timestamp precision loss in the cursor.

Comment thread src/repository/user.ts
Comment on lines +303 to +330
if (cursor && useKeyset) {
const keyset = parseKeysetCursor(cursor);
if (keyset) {
if (sortBy === "createdAt") {
const d = new Date(keyset.v);
if (!Number.isNaN(d.getTime())) {
if (sortOrder === "asc") {
conditions.push(sql`(${sortColumn}, ${users.id}) > (${d}, ${keyset.id})`);
} else {
conditions.push(
sql`(${sortColumn} < ${d} OR (${sortColumn} = ${d} AND ${users.id} > ${keyset.id}))`
);
}
}
} else {
if (sortOrder === "asc") {
conditions.push(sql`(${sortColumn}, ${users.id}) > (${keyset.v}, ${keyset.id})`);
} else {
conditions.push(
sql`(${sortColumn} < ${keyset.v} OR (${sortColumn} = ${keyset.v} AND ${users.id} > ${keyset.id}))`
);
}
}
}
} else if (cursor) {
// Offset fallback for nullable columns
offset = Math.max(Number(cursor) || 0, 0);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There is a logic error in the keyset pagination fallback. If useKeyset is true but the cursor is not a valid JSON keyset (e.g., it's a numeric string from a previous session or a different sort column), the code enters the first block, parseKeysetCursor returns null, and the offset remains 0. This effectively resets the pagination to the first page instead of falling back to offset-based pagination. The fallback logic should be moved outside the if (keyset) block or handled more gracefully.

Comment thread src/repository/user.ts
Comment on lines +163 to +173
function parseKeysetCursor(raw: string): KeysetCursor | null {
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) };
}
} catch {
// not JSON - treat as offset
}
return null;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The function parseKeysetCursor uses JSON.parse() on the raw input string, which is derived from the user-provided cursor parameter. A malicious user can provide a very large JSON string (a "JSON bomb") that can cause the server to consume excessive CPU and memory during parsing, leading to a Denial of Service (DoS). There is no size validation on the input before it is parsed.

function parseKeysetCursor(raw: string): KeysetCursor | null {
  if (raw.length > 1024) { // Add a reasonable size limit
    return null;
  }
  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) };
    }
  } catch {
    // not JSON - treat as offset
  }
  return null;
}

Comment thread src/repository/user.ts
Comment on lines +307 to +316
const d = new Date(keyset.v);
if (!Number.isNaN(d.getTime())) {
if (sortOrder === "asc") {
conditions.push(sql`(${sortColumn}, ${users.id}) > (${d}, ${keyset.id})`);
} else {
conditions.push(
sql`(${sortColumn} < ${d} OR (${sortColumn} = ${d} AND ${users.id} > ${keyset.id}))`
);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using Date.toISOString() for keyset pagination on a timestamp with time zone column in PostgreSQL can lead to duplicates or skipped rows. JS Date objects and toISOString() only provide millisecond precision, whereas PostgreSQL timestamps typically have microsecond precision. When comparing col = d or using row constructors, the equality check will likely fail for records with non-zero microseconds, causing the pagination to skip the tie-breaker logic or re-include the last row of the previous page. Consider using date_trunc('milliseconds', sortColumn) in the comparison or selecting the timestamp as text with full precision for the cursor.

Comment on lines +220 to +228
const usageByKeyId = useMemo(() => {
const merged: Record<number, KeyUsageData> = {};
for (const query of usageQueries) {
if (query.data?.ok) {
Object.assign(merged, query.data.data.usageByKeyId);
}
}
return merged;
}, [usageQueries]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The usageByKeyId memoization depends on the usageQueries array. In TanStack Query (especially versions prior to v5 or without the combine option), useQueries returns a new array reference on every render. This causes the merge logic and the subsequent allUsers memo to re-run on every render of the component, even if the data hasn't changed. If you are using TanStack Query v5, consider using the combine property in useQueries to produce a stable merged result.

…print

useQueries returns a new array reference every render, causing useMemo
to recompute unconditionally. Use dataUpdatedAt timestamps as a stable
dependency so the memo only recomputes when actual query data arrives.
@ding113
ding113 merged commit 90fbeaf into dev Feb 23, 2026
7 of 9 checks passed
@github-project-automation github-project-automation Bot moved this from Backlog to Done in Claude Code Hub Roadmap Feb 23, 2026

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 files reviewed, 7 comments

Edit Code Review Agent Settings | Greptile

Comment thread src/repository/user.ts
Comment on lines +370 to +373
if (useKeyset) {
const lastRow = usersToReturn[usersToReturn.length - 1];
const rawValue = sortBy === "name" ? lastRow.name : lastRow.createdAt;
nextCursor = encodeKeysetCursor(rawValue, lastRow.id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hardcoded ternary only handles name, assumes all other keyset columns are createdAt — breaks if KEYSET_SORT_COLUMNS expands

Suggested change
if (useKeyset) {
const lastRow = usersToReturn[usersToReturn.length - 1];
const rawValue = sortBy === "name" ? lastRow.name : lastRow.createdAt;
nextCursor = encodeKeysetCursor(rawValue, lastRow.id);
const lastRow = usersToReturn[usersToReturn.length - 1];
const rawValue = sortColumn; // use the already-resolved sortColumn reference
nextCursor = encodeKeysetCursor(rawValue, lastRow.id);
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/repository/user.ts
Line: 370-373

Comment:
hardcoded ternary only handles `name`, assumes all other keyset columns are `createdAt` — breaks if `KEYSET_SORT_COLUMNS` expands

```suggestion
      const lastRow = usersToReturn[usersToReturn.length - 1];
      const rawValue = sortColumn; // use the already-resolved sortColumn reference
      nextCursor = encodeKeysetCursor(rawValue, lastRow.id);
```

How can I resolve this? If you propose a fix, please make it concise.

Comment thread src/repository/user.ts
Comment on lines +306 to +325
if (sortBy === "createdAt") {
const d = new Date(keyset.v);
if (!Number.isNaN(d.getTime())) {
if (sortOrder === "asc") {
conditions.push(sql`(${sortColumn}, ${users.id}) > (${d}, ${keyset.id})`);
} else {
conditions.push(
sql`(${sortColumn} < ${d} OR (${sortColumn} = ${d} AND ${users.id} > ${keyset.id}))`
);
}
}
} else {
if (sortOrder === "asc") {
conditions.push(sql`(${sortColumn}, ${users.id}) > (${keyset.v}, ${keyset.id})`);
} else {
conditions.push(
sql`(${sortColumn} < ${keyset.v} OR (${sortColumn} = ${keyset.v} AND ${users.id} > ${keyset.id}))`
);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

keyset cursor parsing branches on sortBy === "createdAt" to parse date vs string, but fails to validate that the parsed value type matches the column type before using it in the WHERE clause

If a malformed cursor with a string value is sent for createdAt sort, new Date(keyset.v) could parse successfully but produce an invalid date, or if a numeric string is sent for name sort, it bypasses validation. Add validation that keyset.v matches expected format.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/repository/user.ts
Line: 306-325

Comment:
keyset cursor parsing branches on `sortBy === "createdAt"` to parse date vs string, but fails to validate that the parsed value type matches the column type before using it in the WHERE clause

If a malformed cursor with a string value is sent for `createdAt` sort, `new Date(keyset.v)` could parse successfully but produce an invalid date, or if a numeric string is sent for `name` sort, it bypasses validation. Add validation that `keyset.v` matches expected format.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread src/repository/user.ts
Comment on lines +318 to +324
if (sortOrder === "asc") {
conditions.push(sql`(${sortColumn}, ${users.id}) > (${keyset.v}, ${keyset.id})`);
} else {
conditions.push(
sql`(${sortColumn} < ${keyset.v} OR (${sortColumn} = ${keyset.v} AND ${users.id} > ${keyset.id}))`
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for string columns like name, this assumes the column value can be directly compared with the string from the cursor, but doesn't handle potential SQL injection if keyset.v contains special characters

While drizzle-orm should handle parameterization, explicitly validating or sanitizing the cursor value would make this more defensive.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/repository/user.ts
Line: 318-324

Comment:
for string columns like `name`, this assumes the column value can be directly compared with the string from the cursor, but doesn't handle potential SQL injection if `keyset.v` contains special characters

While drizzle-orm should handle parameterization, explicitly validating or sanitizing the cursor value would make this more defensive.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +200 to +203
const pageUserIds = useMemo(
() => (data?.pages ?? []).map((page) => page.users.map((u) => u.id)),
[data]
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the pageUserIds memo flattens all loaded pages into a 2D array, but this creates a new array reference on EVERY pagination event (when data.pages changes)

This triggers all usageQueries to re-create their query descriptors even when only a new page is appended. Consider a more stable structure like a Map keyed by page index.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/[locale]/dashboard/users/users-page-client.tsx
Line: 200-203

Comment:
the `pageUserIds` memo flattens all loaded pages into a 2D array, but this creates a new array reference on EVERY pagination event (when `data.pages` changes)

This triggers all `usageQueries` to re-create their query descriptors even when only a new page is appended. Consider a more stable structure like a Map keyed by page index.

<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +237 to +241
if (Object.keys(usageByKeyId).length === 0) return coreUsers;
return coreUsers.map((user) => {
const hasUsageForAnyKey = user.keys.some((k) => k.id in usageByKeyId);
if (!hasUsageForAnyKey) return user;
return {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check hasUsageForAnyKey before cloning the entire user object, but then clone the user anyway if true — inefficient for users with many keys where only some have usage data

Suggested change
if (Object.keys(usageByKeyId).length === 0) return coreUsers;
return coreUsers.map((user) => {
const hasUsageForAnyKey = user.keys.some((k) => k.id in usageByKeyId);
if (!hasUsageForAnyKey) return user;
return {
return coreUsers.map((user) => {
const keysWithUsage = user.keys.map((key) => {
const usage = usageByKeyId[key.id];
return usage ? { ...key, ...usage } : key;
});
return keysWithUsage === user.keys ? user : { ...user, keys: keysWithUsage };
});

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/[locale]/dashboard/users/users-page-client.tsx
Line: 237-241

Comment:
check `hasUsageForAnyKey` before cloning the entire user object, but then clone the user anyway if true — inefficient for users with many keys where only some have usage data

```suggestion
    return coreUsers.map((user) => {
      const keysWithUsage = user.keys.map((key) => {
        const usage = usageByKeyId[key.id];
        return usage ? { ...key, ...usage } : key;
      });
      return keysWithUsage === user.keys ? user : { ...user, keys: keysWithUsage };
    });
```

<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>

How can I resolve this? If you propose a fix, please make it concise.

Comment thread src/actions/users.ts
Comment on lines +781 to +784
const sanitizedIds = Array.from(new Set(userIds)).filter((id) => Number.isInteger(id));
if (sanitizedIds.length === 0) {
return { ok: true, data: { usageByKeyId: {} } };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

filters out non-integer IDs but doesn't check for negative integers or zero, which could cause unexpected database queries or errors

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/actions/users.ts
Line: 781-784

Comment:
filters out non-integer IDs but doesn't check for negative integers or zero, which could cause unexpected database queries or errors

How can I resolve this? If you propose a fix, please make it concise.

Comment thread src/actions/users.ts
Comment on lines 239 to 245
// Instead of N*3 queries (one per user for keys, usage, statistics),
// we now do 3 batch queries total
const userIds = users.map((u) => u.id);
const [keysMap, usageMap, statisticsMap] = await Promise.all([
const [keysMap, usageMap] = await Promise.all([
findKeyListBatch(userIds),
findKeyUsageTodayBatch(userIds),
findKeysWithStatisticsBatch(userIds),
]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changed to call findKeysStatisticsBatchFromKeys after awaiting keysMap instead of running in parallel with findKeyUsageTodayBatch — adds sequential latency

The original parallel structure was better for performance. The new function accepts a keysMap to avoid redundant findKeyListBatch, but you're still fetching keys here, so the optimization doesn't apply. Revert to parallel or pass keysMap properly.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/actions/users.ts
Line: 239-245

Comment:
changed to call `findKeysStatisticsBatchFromKeys` after awaiting `keysMap` instead of running in parallel with `findKeyUsageTodayBatch` — adds sequential latency

The original parallel structure was better for performance. The new function accepts a keysMap to avoid redundant `findKeyListBatch`, but you're still fetching keys here, so the optimization doesn't apply. Revert to parallel or pass keysMap properly.

How can I resolve this? If you propose a fix, please make it concise.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Summary

This PR implements lazy-loading of usage data on the users page to improve initial render performance. The approach is sound: splitting data fetching into a fast "core" phase (users + keys) and a background "usage" phase. However, there are several issues that need to be addressed before merging.

PR Size: M

  • Lines changed: 458 (424 additions, 34 deletions)
  • Files changed: 5

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 1 0 0 0
Comments/Docs 0 2 0 0
Tests 0 2 0 0
Simplification 0 0 0 0

Critical Issues (Must Fix)

[TYPE-MISMATCH] UserListBatchFilters.cursor type mismatch in src/repository/user.ts:11

The interface declares cursor?: number but the implementation now receives JSON-encoded keyset cursors (strings). This is a breaking type contract issue.

Current:

export interface UserListBatchFilters {
  /** Offset pagination cursor */
  cursor?: number;

Fix:

export interface UserListBatchFilters {
  /** Cursor for pagination (JSON-encoded keyset or numeric offset) */
  cursor?: string;

Also update UserListBatchResult.nextCursor on line 40 from number | null to string | null.

High Priority Issues (Should Fix)

  1. [COMMENT-OUTDATED] src/repository/user.ts:10-11 and src/repository/user.ts:152

    The JSDoc comments describe "Offset pagination cursor" and "Offset-based pagination" but the implementation uses hybrid cursor pagination. Update comments to reflect the new keyset cursor approach.

  2. [TEST-MISSING] Missing tests for getUsersBatchCore and getUsersUsageBatch in src/actions/users.ts

    Per CLAUDE.md: "All new features must have unit test coverage of at least 80%". These new server actions lack any test coverage for:

    • Permission checks (admin only)
    • Batch size validation (max 500)
    • Empty state handling
    • Usage data aggregation logic

    Add tests in tests/unit/users-actions.test.ts.

Review Coverage

  • Logic and correctness - Clean
  • Security (OWASP Top 10) - Clean
  • Error handling - Proper error logging and user feedback
  • Type safety - One type mismatch identified
  • Documentation accuracy - Two outdated comments
  • Test coverage - Insufficient for new functions
  • Code clarity - Good, with minor simplification opportunities

Automated review by Claude AI

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/app/`[locale]/dashboard/users/users-page-client.tsx:
- Around line 205-214: The current usageQueries queryFn returns the ActionResult
from getUsersUsageBatch, so failures with ok: false are swallowed and React
Query never enters error state; update the query function used in usageQueries
(the mapping over pageUserIds) to inspect the returned ActionResult from
getUsersUsageBatch and when result.ok is false either throw an Error (with the
result error message/details) so TanStack Query marks the query as error and
triggers retries, or at minimum emit a console.warn with the error details
before returning; ensure you reference getUsersUsageBatch and the usageQueries
mapping over pageUserIds so the change applies to each queryFn.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Cache: Disabled due to Reviews > Disable Cache setting

📥 Commits

Reviewing files that changed from the base of the PR and between 3d1e53c and a5a49d1.

📒 Files selected for processing (1)
  • src/app/[locale]/dashboard/users/users-page-client.tsx

Comment on lines +205 to +214
const usageQueries = useQueries({
queries: isAdmin
? pageUserIds.map((ids) => ({
queryKey: ["users-usage", ids],
queryFn: () => getUsersUsageBatch(ids),
enabled: ids.length > 0,
staleTime: 60_000,
refetchOnWindowFocus: false,
}))
: [],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

getUsersUsageBatch 失败时错误被完全吞没

queryFn 直接返回 ActionResult,当 ok: false 时不会抛出异常。TanStack Query 规定:只有 queryFn 抛出异常或返回被拒绝的 Promise,Query 才会进入 error 状态。这导致:

  1. query.isError 永远为 false,React Query 的默认重试(3 次)不会触发;
  2. 使用数据静默返回全 0(coreUsers 初始值),既无控制台警告也无 UI 提示,给调试带来困难。

若这是有意为之的降级策略,建议至少在失败时添加一条 console.warn,以便排查。

建议修改
-      queryFn: () => getUsersUsageBatch(ids),
+      queryFn: async () => {
+        const result = await getUsersUsageBatch(ids);
+        if (!result.ok) {
+          console.warn("[users-usage] Failed to load usage batch:", result.error);
+        }
+        return result;
+      },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const usageQueries = useQueries({
queries: isAdmin
? pageUserIds.map((ids) => ({
queryKey: ["users-usage", ids],
queryFn: () => getUsersUsageBatch(ids),
enabled: ids.length > 0,
staleTime: 60_000,
refetchOnWindowFocus: false,
}))
: [],
const usageQueries = useQueries({
queries: isAdmin
? pageUserIds.map((ids) => ({
queryKey: ["users-usage", ids],
queryFn: async () => {
const result = await getUsersUsageBatch(ids);
if (!result.ok) {
console.warn("[users-usage] Failed to load usage batch:", result.error);
}
return result;
},
enabled: ids.length > 0,
staleTime: 60_000,
refetchOnWindowFocus: false,
}))
: [],
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`[locale]/dashboard/users/users-page-client.tsx around lines 205 -
214, The current usageQueries queryFn returns the ActionResult from
getUsersUsageBatch, so failures with ok: false are swallowed and React Query
never enters error state; update the query function used in usageQueries (the
mapping over pageUserIds) to inspect the returned ActionResult from
getUsersUsageBatch and when result.ok is false either throw an Error (with the
result error message/details) so TanStack Query marks the query as error and
triggers retries, or at minimum emit a console.warn with the error details
before returning; ensure you reference getUsersUsageBatch and the usageQueries
mapping over pageUserIds so the change applies to each queryFn.

Comment thread src/repository/user.ts
if (!Number.isNaN(d.getTime())) {
if (sortOrder === "asc") {
conditions.push(sql`(${sortColumn}, ${users.id}) > (${d}, ${keyset.id})`);
} else {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CRITICAL] [LOGIC-BUG] Keyset pagination for createdAt can duplicate/skip rows due to timestamp precision mismatch.

Evidence: src/repository/user.ts:310-314

conditions.push(sql`(${sortColumn}, ${users.id}) > (${d}, ${keyset.id})`);
// ...
sql`(${sortColumn} < ${d} OR (${sortColumn} = ${d} AND ${users.id} > ${keyset.id}))`

Why this is a problem: created_at is timestamp with time zone (microsecond precision), but the cursor is encoded/decoded via JS Date (toISOString() / new Date(...), millisecond precision). When the cursor loses sub-ms precision, the seek condition can include rows from the previous page (ASC) or skip rows (DESC), breaking pagination correctness.

Suggested fix (truncate DB timestamp to milliseconds in keyset comparisons to match JS encoding precision):

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`(${truncCol}, ${users.id}) > (${d}, ${keyset.id})`);
    } else {
      conditions.push(
        sql`(${truncCol} < ${d} OR (${truncCol} = ${d} AND ${users.id} > ${keyset.id}))`
      );
    }
  }
}

? pageUserIds.map((ids) => ({
queryKey: ["users-usage", ids],
queryFn: () => getUsersUsageBatch(ids),
enabled: ids.length > 0,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] [ERROR-NO-USER-FEEDBACK] Background usage fetch errors are silently dropped, leaving usage columns stuck at default 0/null with no UI signal.

Evidence: src/app/[locale]/dashboard/users/users-page-client.tsx:209-228

queryFn: () => getUsersUsageBatch(ids),
...
if (query.data?.ok) {
  Object.assign(merged, query.data.data.usageByKeyId);
}

Why this is a problem: getUsersUsageBatch returns an ActionResult. When it returns { ok: false, ... }, React Query still treats the request as a success, and the merge step simply skips it. That masks backend failures and can mislead admins into thinking usage is actually zero.

Suggested fix (treat ok: false as a query error + surface it once):

import { toast } from "sonner";

const usageQueries = useQueries({
  queries: isAdmin
    ? pageUserIds.map((ids) => ({
        queryKey: ["users-usage", ids],
        queryFn: async () => {
          const result = await getUsersUsageBatch(ids);
          if (!result.ok) throw new Error(result.error);
          return result.data;
        },
        retry: false,
        onError: (err) => {
          toast.error(err instanceof Error ? err.message : tCommon("error"));
        },
        enabled: ids.length > 0,
        staleTime: 60_000,
        refetchOnWindowFocus: false,
      }))
    : [],
});

const usageByKeyId = useMemo(() => {
  const merged: Record<number, KeyUsageData> = {};
  for (const query of usageQueries) {
    if (query.data) Object.assign(merged, query.data.usageByKeyId);
  }
  return merged;
}, [usageDataVersion]);

Comment thread src/actions/users.ts
return {
ok: false,
error: tError("BATCH_SIZE_EXCEEDED"),
errorCode: ERROR_CODES.INVALID_FORMAT,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] [LOGIC-BUG] BATCH_SIZE_EXCEEDED i18n message is missing the required {max} interpolation parameter.

Evidence: src/actions/users.ts:785-790

if (sanitizedIds.length > 500) {
  return {
    ok: false,
    error: tError("BATCH_SIZE_EXCEEDED"),
    errorCode: ERROR_CODES.INVALID_FORMAT,
  };
}

Why this is a problem: the translation string for BATCH_SIZE_EXCEEDED contains {max} (e.g. messages/en/errors.json), so returning it without params produces a broken user-facing message.

Suggested fix:

const MAX_BATCH_SIZE = 500;
...
if (sanitizedIds.length > MAX_BATCH_SIZE) {
  return {
    ok: false,
    error: tError("BATCH_SIZE_EXCEEDED", { max: MAX_BATCH_SIZE }),
    errorCode: ERROR_CODES.INVALID_FORMAT,
  };
}

Comment thread src/actions/users.ts
} catch (error) {
logger.error("Failed to fetch user batch core data:", error);
const message = error instanceof Error ? error.message : "Failed to fetch user batch core data";
return { ok: false, error: message, errorCode: ERROR_CODES.INTERNAL_ERROR };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] [STANDARD-VIOLATION] Server-action catch blocks return raw error.message / hardcoded English strings to the client.

Evidence: src/actions/users.ts:742-746, src/actions/users.ts:828-833

const message = error instanceof Error ? error.message : "Failed to fetch user batch core data";
return { ok: false, error: message, errorCode: ERROR_CODES.INTERNAL_ERROR };

Why this is a problem: these error strings are surfaced to the UI (the client wraps result.error in new Error(...)). Returning raw exception messages bypasses i18n and can leak internal details (DB errors, stack-adjacent messages) into the dashboard.

Suggested fix (log the real error, return a localized generic message):

} catch (error) {
  logger.error("Failed to fetch user batch core data:", error);
  const tError = await getTranslations("errors");
  return {
    ok: false,
    error: tError("INTERNAL_ERROR"),
    errorCode: ERROR_CODES.INTERNAL_ERROR,
  };
}

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Summary

This PR introduces two-phase loading for the Users dashboard and adds hybrid cursor pagination (keyset for name/createdAt, OFFSET fallback elsewhere). The performance goal is clear, but there are correctness risks in the new keyset cursor logic for createdAt ordering and multiple error paths that either fail silently or surface non-localized/internal messages.

PR Size: M

  • Lines changed: 458
  • Files changed: 5

Issues Found

Category Critical High Medium Low
Logic/Bugs 1 0 1 0
Security 0 0 1 0
Error Handling 0 1 1 0
Types 0 0 0 0
Comments/Docs 0 0 0 0
Tests 0 0 0 0
Simplification 0 0 0 0

Critical Issues (Must Fix)

  • src/repository/user.ts:310 Keyset pagination for createdAt uses ms-precision cursors against microsecond DB timestamps, which can duplicate/skip rows at page boundaries (confidence 97).

High Priority Issues (Should Fix)

  • src/app/[locale]/dashboard/users/users-page-client.tsx:209 Background usage fetch drops { ok: false } results with no UI feedback, masking backend failures and leaving usage at defaults (confidence 90).
  • src/actions/users.ts:785 tError("BATCH_SIZE_EXCEEDED") is missing the { max } param, producing broken i18n output (confidence 90).
  • src/actions/users.ts:744 Catch blocks return raw error.message / hardcoded English strings to the UI, bypassing i18n and potentially leaking internal details (confidence 85).

Review Coverage

  • Logic and correctness
  • Security (OWASP Top 10)
  • Error handling
  • Type safety
  • Documentation accuracy
  • Test coverage
  • Code clarity

Automated review by Codex AI

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied PR size label size/M (458 lines changed across 5 files) and submitted the review on PR #818.

Inline comments posted (with file+line evidence + concrete fixes):

  • src/repository/user.ts keyset pagination for createdAt can duplicate/skip rows due to microsecond DB timestamps vs millisecond JS Date cursor encoding.
  • src/app/[locale]/dashboard/users/users-page-client.tsx usage lazy-load errors are silently dropped (no user feedback, defaults remain 0/null).
  • src/actions/users.ts tError("BATCH_SIZE_EXCEEDED") missing { max } interpolation param.
  • src/actions/users.ts catch blocks return raw error.message / hardcoded English strings to the UI (non-i18n + potential info leak).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:statistics area:UI enhancement New feature or request size/M Medium PR (< 500 lines)

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant