Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions convex/users.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1024,10 +1024,11 @@ describe("users.searchInternal", () => {
await expect(handler(ctx, { actorUserId: "users:missing" })).rejects.toThrow("Unauthorized");
});

it("uses bounded scan and returns mapped fields", async () => {
it("searches across the full user list and returns mapped fields", async () => {
const users = [
{ _id: "users:1", _creationTime: 2, handle: "alice", name: "alice", role: "user" },
{ _id: "users:2", _creationTime: 1, handle: "bob", name: "bob", role: "moderator" },
{ _id: "users:1", _creationTime: 3, handle: "zoe", name: "zoe", role: "user" },
{ _id: "users:2", _creationTime: 2, handle: "bob", name: "bob", role: "moderator" },
{ _id: "users:3", _creationTime: 1, handle: "alice", name: "alice", role: "user" },
];
const { ctx, take, collect, get } = makeListCtx(users);
const handler = (
Expand All @@ -1044,12 +1045,12 @@ describe("users.searchInternal", () => {
total: number;
};

expect(take).toHaveBeenCalledWith(500);
expect(collect).not.toHaveBeenCalled();
expect(collect).toHaveBeenCalledTimes(1);
expect(take).not.toHaveBeenCalled();
expect(result.total).toBe(1);
expect(result.items).toEqual([
{
userId: "users:1",
userId: "users:3",
handle: "alice",
displayName: null,
name: "alice",
Expand Down Expand Up @@ -1080,7 +1081,7 @@ describe("users.searchInternal", () => {
);
});

it("clamps limit for empty query and uses non-search path", async () => {
it("still caps empty-query listing and uses non-search path", async () => {
const users = Array.from({ length: 400 }, (_value, index) => ({
_id: `users:${index}`,
_creationTime: 1_000 - index,
Expand Down
11 changes: 5 additions & 6 deletions convex/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
const DEFAULT_ROLE = "user";
const ADMIN_HANDLE = "steipete";
const MAX_USER_LIST_LIMIT = 200;
const MAX_USER_SEARCH_SCAN = 5_000;

Check failure on line 28 in convex/users.ts

View workflow job for this annotation

GitHub Actions / build

eslint(no-unused-vars)

Variable 'MAX_USER_SEARCH_SCAN' is declared but never used. Unused variables should start with a '_'.
const MIN_USER_SEARCH_SCAN = 500;

Check failure on line 29 in convex/users.ts

View workflow job for this annotation

GitHub Actions / build

eslint(no-unused-vars)

Variable 'MIN_USER_SEARCH_SCAN' is declared but never used. Unused variables should start with a '_'.

export const getById = query({
args: { userId: v.id("users") },
Expand Down Expand Up @@ -362,15 +362,14 @@
return trimmed ? trimmed : undefined;
}

function computeUserSearchScanLimit(limit: number) {
return clampInt(limit * 10, MIN_USER_SEARCH_SCAN, MAX_USER_SEARCH_SCAN);
}

async function queryUsersForAdminList(
ctx: {
db: {
query: (table: "users") => {
order: (order: "desc") => { take: (n: number) => Promise<Doc<"users">[]> };
order: (order: "desc") => {
take: (n: number) => Promise<Doc<"users">[]>;
collect: () => Promise<Doc<"users">[]>;
};
};
};
},
Expand All @@ -384,7 +383,7 @@
return { items, total: items.length };
}

const scannedUsers = await orderedUsers.take(computeUserSearchScanLimit(args.limit));
const scannedUsers = await orderedUsers.collect();
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

MAX_USER_SEARCH_SCAN/MIN_USER_SEARCH_SCAN are now unused after removing computeUserSearchScanLimit. With noUnusedLocals: true in tsconfig, this will fail TypeScript compilation; remove these constants or reintroduce usage (e.g., as a cap for the search scan).

Suggested change
const scannedUsers = await orderedUsers.collect();
const scanLimit = clampInt(args.limit, MIN_USER_SEARCH_SCAN, MAX_USER_SEARCH_SCAN);
const scannedUsers = await orderedUsers.take(scanLimit);

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Restore bounded scan for admin user search

Changing queryUsersForAdminList from a bounded take(...) to collect() makes every non-empty admin search read the entire users table, which can hit Convex query limits (document/bytes read caps, including the 32K-doc ceiling) as the user base grows. In that state, searchInternal/list with a search term will error instead of returning matches, so this is a functional regression plus a significant bandwidth/cost increase.

Useful? React with 👍 / 👎.

const result = buildUserSearchResults(scannedUsers, normalizedSearch);
Comment on lines 379 to 382
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

Calling orderedUsers.collect() for every non-empty search performs an unbounded full-table scan of the users table. This will scale linearly with user count and may hit Convex execution/response limits as the dataset grows; consider adding a bounded/paginated scan strategy or a dedicated search/index-based approach instead of collecting all users at once.

Copilot uses AI. Check for mistakes.
return { items: result.items.slice(0, args.limit), total: result.total };
}
Expand Down
Loading