Răsfoiți Sursa

fix: resolve merge conflict with dev (keep JSDoc + renamed functions)

ding113 1 lună în urmă
părinte
comite
b06bdba249

+ 239 - 7
src/actions/users.ts

@@ -21,7 +21,7 @@ import {
   createKey,
   findKeyList,
   findKeyListBatch,
-  findKeysWithStatisticsBatch,
+  findKeysStatisticsBatchFromKeys,
   findKeyUsageTodayBatch,
 } from "@/repository/key";
 import {
@@ -42,7 +42,7 @@ import type { ActionResult } from "./types";
  * 批量获取用户列表的查询参数(用于用户管理列表页)。
  */
 export interface GetUsersBatchParams {
-  cursor?: number;
+  cursor?: string;
   limit?: number;
   searchTerm?: string;
   tagFilters?: string[];
@@ -66,10 +66,34 @@ export interface GetUsersBatchParams {
  */
 export interface GetUsersBatchResult {
   users: UserDisplay[];
-  nextCursor: number | null;
+  nextCursor: string | null;
   hasMore: boolean;
 }
 
+/**
+ * Usage data for a single key (lazy-loaded separately from core user data).
+ */
+export interface KeyUsageData {
+  todayUsage: number;
+  todayCallCount: number;
+  todayTokens: number;
+  lastUsedAt: Date | null;
+  lastProviderName: string | null;
+  modelStats: Array<{
+    model: string;
+    callCount: number;
+    totalCost: number;
+    inputTokens: number;
+    outputTokens: number;
+    cacheCreationTokens: number;
+    cacheReadTokens: number;
+  }>;
+}
+
+export interface GetUsersUsageBatchResult {
+  usageByKeyId: Record<number, KeyUsageData>;
+}
+
 /**
  * 批量更新的结果统计(便于前端展示成功/失败数量)。
  */
@@ -215,11 +239,11 @@ export async function getUsers(): Promise<UserDisplay[]> {
     // 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),
     ]);
+    const statisticsMap = await findKeysStatisticsBatchFromKeys(keysMap);
 
     const userDisplays: UserDisplay[] = users.map((user) => {
       try {
@@ -486,11 +510,11 @@ export async function getUsersBatch(
     }
 
     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),
     ]);
+    const statisticsMap = await findKeysStatisticsBatchFromKeys(keysMap);
 
     const userDisplays: UserDisplay[] = users.map((user) => {
       try {
@@ -603,6 +627,214 @@ export async function getUsersBatch(
   }
 }
 
+/**
+ * Fast version of getUsersBatch: returns users + keys only (no usage/statistics).
+ * Usage fields are filled with defaults (0 / null / []).
+ * Designed for instant initial render; usage data loaded separately via getUsersUsageBatch.
+ *
+ * Admin only.
+ */
+export async function getUsersBatchCore(
+  params: GetUsersBatchParams
+): Promise<ActionResult<GetUsersBatchResult>> {
+  try {
+    const tError = await getTranslations("errors");
+
+    const session = await getSession();
+    if (!session) {
+      return {
+        ok: false,
+        error: tError("UNAUTHORIZED"),
+        errorCode: ERROR_CODES.UNAUTHORIZED,
+      };
+    }
+    if (session.user.role !== "admin") {
+      return {
+        ok: false,
+        error: tError("PERMISSION_DENIED"),
+        errorCode: ERROR_CODES.PERMISSION_DENIED,
+      };
+    }
+
+    const locale = await getLocale();
+    const t = await getTranslations("users");
+
+    const { users, nextCursor, hasMore } = await findUserListBatch({
+      cursor: params.cursor,
+      limit: params.limit,
+      searchTerm: params.searchTerm,
+      tagFilters: params.tagFilters,
+      keyGroupFilters: params.keyGroupFilters,
+      statusFilter: params.statusFilter,
+      sortBy: params.sortBy,
+      sortOrder: params.sortOrder,
+    });
+
+    if (users.length === 0) {
+      return { ok: true, data: { users: [], nextCursor, hasMore } };
+    }
+
+    const userIds = users.map((u) => u.id);
+    const keysMap = await findKeyListBatch(userIds);
+
+    const userDisplays: UserDisplay[] = users.map((user) => {
+      const keys = keysMap.get(user.id) || [];
+
+      return {
+        id: user.id,
+        name: user.name,
+        note: user.description || undefined,
+        role: user.role,
+        rpm: user.rpm,
+        dailyQuota: user.dailyQuota,
+        providerGroup: user.providerGroup || undefined,
+        tags: user.tags || [],
+        limit5hUsd: user.limit5hUsd ?? null,
+        limitWeeklyUsd: user.limitWeeklyUsd ?? null,
+        limitMonthlyUsd: user.limitMonthlyUsd ?? null,
+        limitTotalUsd: user.limitTotalUsd ?? null,
+        limitConcurrentSessions: user.limitConcurrentSessions ?? null,
+        dailyResetMode: user.dailyResetMode,
+        dailyResetTime: user.dailyResetTime,
+        isEnabled: user.isEnabled,
+        expiresAt: user.expiresAt ?? null,
+        allowedClients: user.allowedClients || [],
+        blockedClients: user.blockedClients || [],
+        allowedModels: user.allowedModels ?? [],
+        keys: keys.map((key) => ({
+          id: key.id,
+          name: key.name,
+          maskedKey: maskKey(key.key),
+          fullKey: key.key,
+          canCopy: true,
+          expiresAt: key.expiresAt ? key.expiresAt.toISOString().split("T")[0] : t("neverExpires"),
+          status: key.isEnabled ? "enabled" : ("disabled" as const),
+          createdAt: key.createdAt,
+          createdAtFormatted: key.createdAt.toLocaleString(locale, {
+            year: "numeric",
+            month: "2-digit",
+            day: "2-digit",
+            hour: "2-digit",
+            minute: "2-digit",
+            second: "2-digit",
+          }),
+          todayUsage: 0,
+          todayTokens: 0,
+          todayCallCount: 0,
+          lastUsedAt: null,
+          lastProviderName: null,
+          modelStats: [],
+          canLoginWebUi: key.canLoginWebUi,
+          limit5hUsd: key.limit5hUsd,
+          limitDailyUsd: key.limitDailyUsd,
+          dailyResetMode: key.dailyResetMode,
+          dailyResetTime: key.dailyResetTime,
+          limitWeeklyUsd: key.limitWeeklyUsd,
+          limitMonthlyUsd: key.limitMonthlyUsd,
+          limitTotalUsd: key.limitTotalUsd,
+          limitConcurrentSessions: key.limitConcurrentSessions || 0,
+          providerGroup: key.providerGroup,
+        })),
+      };
+    });
+
+    return { ok: true, data: { users: userDisplays, nextCursor, hasMore } };
+  } 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 };
+  }
+}
+
+/**
+ * Lazy-load usage data for a batch of users.
+ * Called after getUsersBatchCore to populate usage fields in the background.
+ *
+ * Admin only.
+ */
+export async function getUsersUsageBatch(
+  userIds: number[]
+): Promise<ActionResult<GetUsersUsageBatchResult>> {
+  try {
+    const tError = await getTranslations("errors");
+
+    const session = await getSession();
+    if (!session) {
+      return {
+        ok: false,
+        error: tError("UNAUTHORIZED"),
+        errorCode: ERROR_CODES.UNAUTHORIZED,
+      };
+    }
+    if (session.user.role !== "admin") {
+      return {
+        ok: false,
+        error: tError("PERMISSION_DENIED"),
+        errorCode: ERROR_CODES.PERMISSION_DENIED,
+      };
+    }
+
+    if (userIds.length === 0) {
+      return { ok: true, data: { usageByKeyId: {} } };
+    }
+
+    const sanitizedIds = Array.from(new Set(userIds)).filter(
+      (id) => Number.isInteger(id) && id > 0
+    );
+    if (sanitizedIds.length === 0) {
+      return { ok: true, data: { usageByKeyId: {} } };
+    }
+    if (sanitizedIds.length > 500) {
+      return {
+        ok: false,
+        error: tError("BATCH_SIZE_EXCEEDED"),
+        errorCode: ERROR_CODES.INVALID_FORMAT,
+      };
+    }
+
+    const [keysMap, usageMap] = await Promise.all([
+      findKeyListBatch(sanitizedIds),
+      findKeyUsageTodayBatch(sanitizedIds),
+    ]);
+
+    const statisticsMap = await findKeysStatisticsBatchFromKeys(keysMap);
+
+    const usageByKeyId: Record<number, KeyUsageData> = {};
+
+    for (const [userId, userKeys] of keysMap) {
+      const usageRecords = usageMap.get(userId) || [];
+      const keyStatistics = statisticsMap.get(userId) || [];
+
+      const usageLookup = new Map(
+        usageRecords.map((item) => [
+          item.keyId,
+          { totalCost: item.totalCost ?? 0, totalTokens: item.totalTokens ?? 0 },
+        ])
+      );
+      const statisticsLookup = new Map(keyStatistics.map((stat) => [stat.keyId, stat]));
+
+      for (const key of userKeys) {
+        const stats = statisticsLookup.get(key.id);
+        usageByKeyId[key.id] = {
+          todayUsage: usageLookup.get(key.id)?.totalCost ?? 0,
+          todayCallCount: stats?.todayCallCount ?? 0,
+          todayTokens: usageLookup.get(key.id)?.totalTokens ?? 0,
+          lastUsedAt: stats?.lastUsedAt ?? null,
+          lastProviderName: stats?.lastProviderName ?? null,
+          modelStats: stats?.modelStats ?? [],
+        };
+      }
+    }
+
+    return { ok: true, data: { usageByKeyId } };
+  } catch (error) {
+    logger.error("Failed to fetch user usage batch data:", error);
+    const message =
+      error instanceof Error ? error.message : "Failed to fetch user usage batch data";
+    return { ok: false, error: message, errorCode: ERROR_CODES.INTERNAL_ERROR };
+  }
+}
+
 /**
  * 批量更新用户(事务保证原子性)
  *

+ 73 - 5
src/app/[locale]/dashboard/users/users-page-client.tsx

@@ -1,10 +1,17 @@
 "use client";
 
-import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useInfiniteQuery, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
 import { Layers, Loader2, Plus, Search, ShieldCheck } from "lucide-react";
 import { useTranslations } from "next-intl";
 import { useCallback, useEffect, useMemo, useState } from "react";
-import { getAllUserKeyGroups, getAllUserTags, getUsers, getUsersBatch } from "@/actions/users";
+import type { KeyUsageData } from "@/actions/users";
+import {
+  getAllUserKeyGroups,
+  getAllUserTags,
+  getUsers,
+  getUsersBatchCore,
+  getUsersUsageBatch,
+} from "@/actions/users";
 import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";
 import {
@@ -137,7 +144,7 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
         return { users, nextCursor: null, hasMore: false };
       }
 
-      const result = await getUsersBatch({
+      const result = await getUsersBatchCore({
         cursor: pageParam,
         limit: 50,
         searchTerm: resolvedSearchTerm,
@@ -153,7 +160,7 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
       return result.data;
     },
     getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
-    initialPageParam: 0,
+    initialPageParam: undefined as string | undefined,
     placeholderData: (previousData) => previousData,
   });
 
@@ -189,7 +196,67 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
     staleTime: 30_000,
   });
 
-  const allUsers = useMemo(() => data?.pages.flatMap((page) => page.users) ?? [], [data]);
+  // Per-page usage queries: fire independently for each loaded page
+  const pageUserIds = useMemo(
+    () => (data?.pages ?? []).map((page) => page.users.map((u) => u.id)),
+    [data]
+  );
+
+  const usageQueries = useQueries({
+    queries: isAdmin
+      ? pageUserIds.map((ids) => ({
+          queryKey: ["users-usage", ids],
+          queryFn: () => getUsersUsageBatch(ids),
+          enabled: ids.length > 0,
+          staleTime: 60_000,
+          refetchOnWindowFocus: false,
+        }))
+      : [],
+  });
+
+  // Stable fingerprint: only changes when a query actually receives new data,
+  // not on every render tick (useQueries returns a new array reference each time).
+  const usageDataVersion = usageQueries.map((q) => q.dataUpdatedAt).join(",");
+
+  // Build merged usageByKeyId lookup from all resolved usage queries.
+  // biome-ignore lint/correctness/useExhaustiveDependencies: usageDataVersion tracks actual data changes; usageQueries ref is unstable
+  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;
+  }, [usageDataVersion]);
+
+  const coreUsers = useMemo(() => data?.pages.flatMap((page) => page.users) ?? [], [data]);
+
+  // Merge usage data into core users
+  const allUsers = useMemo(() => {
+    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 {
+        ...user,
+        keys: user.keys.map((key) => {
+          const usage = usageByKeyId[key.id];
+          if (!usage) return key;
+          return {
+            ...key,
+            todayUsage: usage.todayUsage,
+            todayCallCount: usage.todayCallCount,
+            todayTokens: usage.todayTokens,
+            lastUsedAt: usage.lastUsedAt,
+            lastProviderName: usage.lastProviderName,
+            modelStats: usage.modelStats,
+          };
+        }),
+      };
+    });
+  }, [coreUsers, usageByKeyId]);
+
   const visibleUsers = useMemo(() => {
     if (isAdmin) return allUsers;
     return allUsers.filter((user) => user.id === currentUser.id);
@@ -706,6 +773,7 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
             translations={tableTranslations}
             onRefresh={() => {
               clearUsageCache();
+              queryClient.invalidateQueries({ queryKey: ["users-usage"] });
               refetch();
             }}
             isRefreshing={isRefreshing}

+ 6 - 0
src/lib/cache/session-cache.ts

@@ -123,6 +123,9 @@ export function clearActiveSessionsCache() {
   activeSessionsCache.delete("active_sessions");
 }
 
+/**
+ * 清空所有 Sessions 的缓存(包括活跃和非活跃)
+ */
 export function clearAllSessionsQueryCache() {
   activeSessionsCache.delete("all_sessions");
 }
@@ -131,6 +134,9 @@ export function clearSessionDetailsCache(sessionId: string) {
   sessionDetailsCache.delete(sessionId);
 }
 
+/**
+ * 清空所有 Session 缓存
+ */
 export function clearAllCaches() {
   activeSessionsCache.clear();
   sessionDetailsCache.clear();

+ 26 - 0
src/repository/key.ts

@@ -794,6 +794,21 @@ export async function findKeysWithStatistics(userId: number): Promise<KeyStatist
   return stats;
 }
 
+/**
+ * Batch version of findKeysWithStatistics using a pre-fetched keysMap.
+ * Eliminates the redundant findKeyListBatch call when the caller already has keys.
+ *
+ * Queries: 3 (today call counts, last usage via LATERAL, model statistics).
+ * Callers typically also run findKeyListBatch + findKeyUsageTodayBatch
+ * for a grand total of 5 DB roundtrips.
+ */
+export async function findKeysStatisticsBatchFromKeys(
+  keysMap: Map<number, Key[]>
+): Promise<Map<number, KeyStatistics[]>> {
+  const userIds = Array.from(keysMap.keys());
+  return _findKeysStatisticsBatchInternal(userIds, keysMap);
+}
+
 /**
  * Batch version of findKeysWithStatistics - fetches statistics for multiple users in optimized queries
  * Returns a Map<userId, KeyStatistics[]> for efficient lookup
@@ -814,6 +829,17 @@ export async function findKeysWithStatisticsBatch(
   // Step 1: Get all keys for all users
   const keyMap = await findKeyListBatch(userIds);
 
+  return _findKeysStatisticsBatchInternal(userIds, keyMap);
+}
+
+async function _findKeysStatisticsBatchInternal(
+  userIds: number[],
+  keyMap: Map<number, Key[]>
+): Promise<Map<number, KeyStatistics[]>> {
+  if (userIds.length === 0) {
+    return new Map();
+  }
+
   // Collect all keys and create a keyString -> (userId, keyId) lookup
   const allKeys: Key[] = [];
   const keyStringToInfo = new Map<string, { userId: number; keyId: number }>();

+ 99 - 23
src/repository/user.ts

@@ -8,8 +8,8 @@ import type { CreateUserData, UpdateUserData, User } from "@/types/user";
 import { toUser } from "./_shared/transformers";
 
 export interface UserListBatchFilters {
-  /** Offset pagination cursor */
-  cursor?: number;
+  /** Cursor for pagination (JSON-encoded keyset or numeric offset) */
+  cursor?: string;
   /** Page size */
   limit?: number;
   /** Search in username / note */
@@ -37,7 +37,7 @@ export interface UserListBatchFilters {
 
 export interface UserListBatchResult {
   users: User[];
-  nextCursor: number | null;
+  nextCursor: string | null;
   hasMore: boolean;
 }
 
@@ -148,11 +148,42 @@ export async function searchUsersForFilter(
     })
     .from(users)
     .where(and(...conditions))
-    .orderBy(sql`CASE WHEN ${users.role} = 'admin' THEN 0 ELSE 1 END`, users.id);
+    .orderBy(sql`CASE WHEN ${users.role} = 'admin' THEN 0 ELSE 1 END`, users.id)
+    .limit(200);
+}
+
+/** Sort columns that are NOT NULL and support keyset cursor pagination */
+const KEYSET_SORT_COLUMNS = new Set<string>(["name", "createdAt"]);
+
+interface KeysetCursor {
+  v: string; // sort column value (ISO string for dates, raw string for text)
+  id: number; // tie-breaker user ID
+}
+
+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.id);
+      if (!Number.isFinite(id) || !Number.isInteger(id) || id <= 0) return null;
+      return { v: String(parsed.v), id };
+    }
+  } catch {
+    // not JSON - treat as offset
+  }
+  return null;
+}
+
+function encodeKeysetCursor(sortValue: unknown, id: number): string {
+  const v = sortValue instanceof Date ? sortValue.toISOString() : String(sortValue ?? "");
+  return JSON.stringify({ v, id });
 }
 
 /**
- * Offset-based pagination for user list.
+ * Hybrid cursor pagination for user list.
+ * - NOT NULL sort columns (name, createdAt): keyset cursor for O(1) seek
+ * - Nullable sort columns: OFFSET fallback
  */
 export async function findUserListBatch(
   filters: UserListBatchFilters
@@ -168,8 +199,9 @@ export async function findUserListBatch(
     sortOrder = "asc",
   } = filters;
 
-  const conditions = [isNull(users.deletedAt)];
+  const conditions: SQL[] = [isNull(users.deletedAt)];
 
+  const effectiveLimit = Math.min(Math.max(1, limit), 200);
   const trimmedSearch = searchTerm?.trim();
   if (trimmedSearch) {
     const pattern = `%${trimmedSearch}%`;
@@ -228,36 +260,29 @@ export async function findUserListBatch(
   if (statusFilter && statusFilter !== "all") {
     switch (statusFilter) {
       case "active":
-        // User is enabled and either never expires or expires in the future
         conditions.push(
           sql`(${users.expiresAt} IS NULL OR ${users.expiresAt} >= NOW()) AND ${users.isEnabled} = true`
         );
         break;
       case "expired":
-        // User has expired (expiresAt is in the past)
         conditions.push(sql`${users.expiresAt} < NOW()`);
         break;
       case "expiringSoon":
-        // User expires within 7 days
         conditions.push(
           sql`${users.expiresAt} IS NOT NULL AND ${users.expiresAt} >= NOW() AND ${users.expiresAt} <= NOW() + INTERVAL '7 days'`
         );
         break;
       case "enabled":
-        // User is enabled regardless of expiration
         conditions.push(sql`${users.isEnabled} = true`);
         break;
       case "disabled":
-        // User is disabled
         conditions.push(sql`${users.isEnabled} = false`);
         break;
     }
   }
 
-  const offset = Math.max(cursor ?? 0, 0);
-
-  // Fetch limit + 1 to determine if there are more records
-  const fetchLimit = limit + 1;
+  const fetchLimit = effectiveLimit + 1;
+  const useKeyset = KEYSET_SORT_COLUMNS.has(sortBy);
 
   // Build dynamic ORDER BY based on sortBy and sortOrder
   const sortColumn = {
@@ -274,7 +299,45 @@ export async function findUserListBatch(
 
   const orderByClause = sortOrder === "asc" ? asc(sortColumn) : sql`${sortColumn} DESC`;
 
-  const results = await db
+  // 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})`;
+          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}))`
+            );
+          }
+        }
+      } 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 {
+      // Cursor is not valid keyset JSON -- fall back to offset
+      offset = Math.max(Number(cursor) || 0, 0);
+    }
+  } else if (cursor) {
+    // Offset fallback for nullable columns
+    offset = Math.max(Number(cursor) || 0, 0);
+  }
+
+  const query = db
     .select({
       id: users.id,
       name: users.name,
@@ -303,13 +366,26 @@ export async function findUserListBatch(
     .from(users)
     .where(and(...conditions))
     .orderBy(orderByClause, asc(users.id))
-    .limit(fetchLimit)
-    .offset(offset);
-
-  const hasMore = results.length > limit;
-  const usersToReturn = hasMore ? results.slice(0, limit) : results;
-
-  const nextCursor = hasMore ? offset + limit : null;
+    .limit(fetchLimit);
+
+  const results = offset > 0 ? await query.offset(offset) : await query;
+
+  const hasMore = results.length > effectiveLimit;
+  const usersToReturn = hasMore ? results.slice(0, effectiveLimit) : results;
+
+  let nextCursor: string | null = null;
+  if (hasMore) {
+    if (useKeyset) {
+      const lastRow = usersToReturn[usersToReturn.length - 1];
+      const keysetRowValue: Record<string, unknown> = {
+        name: lastRow.name,
+        createdAt: lastRow.createdAt,
+      };
+      nextCursor = encodeKeysetCursor(keysetRowValue[sortBy], lastRow.id);
+    } else {
+      nextCursor = String(offset + effectiveLimit);
+    }
+  }
 
   return {
     users: usersToReturn.map(toUser),

+ 2 - 1
tests/unit/user-repository-search-users-for-filter.test.ts

@@ -18,7 +18,8 @@ vi.mock("drizzle-orm", async (importOriginal) => {
 let resolvedRows: Array<{ id: number; name: string }> = [];
 
 vi.mock("@/drizzle/db", () => {
-  const orderByMock = vi.fn(() => Promise.resolve(resolvedRows));
+  const limitMock = vi.fn(() => Promise.resolve(resolvedRows));
+  const orderByMock = vi.fn(() => ({ limit: limitMock }));
   const whereMock = vi.fn(() => ({ orderBy: orderByMock }));
   const fromMock = vi.fn(() => ({ where: whereMock }));
   const selectMock = vi.fn(() => ({ from: fromMock }));