Ali Madooei
Ali Madooei3w ago

Paginated Queries correctly when multiple concurrent mutations

I need help implementing Paginated Queries correctly when multiple concurrent mutations (create, delete, and update operations) affect the record count and pagination cursor. Consider a ChatGPT-like interface with a list of messages. I'm using paginated queries to fetch the N most recent messages and display them to the user with an option to "Load earlier messages." When a user edits a message, the conversation restarts from that point. For example, in a chat with 100 messages where the last 20 are displayed due to pagination, editing message #85 would trigger two actions: the backend would delete all messages after #85, and the AI would generate a new response to the edited message. This would result in a chat containing 86 messages. Currently, my "update message" mutation performs the following: 1. Update the edited message 2. Add a placeholder message for the AI response 3. Trigger (schedule) an action to delete all messages after the edited message and before the placeholder AI message. 4. Trigger (schedule) an action to connect to OpenAI API and incrementally update the placeholder AI message as the response streams in. My pagination is messed up! As these updates are happening, usePaginatedQuery keeps getting triggered, and I constantly see a "hit error" saying "InvalidCursor: Tried to run a query starting from a cursor, but it looks like this cursor is from a different query."
7 Replies
Convex Bot
Convex Bot3w ago
Thanks for posting in <#1088161997662724167>. Reminder: If you have a Convex Pro account, use the Convex Dashboard to file support tickets. - Provide context: What are you trying to achieve, what is the end-user interaction, what are you seeing? (full error message, command output, etc.) - Use search.convex.dev to search Docs, Stack, and Discord all at once. - Additionally, you can post your questions in the Convex Community's <#1228095053885476985> channel to receive a response from AI. - Avoid tagging staff unless specifically instructed. Thank you!
lee
lee3w ago
This sounds pretty reasonable. Can you share the code for the paginated query? I wouldn't expect the query as you described it to be throwing InvalidCursor errors
Ali Madooei
Ali MadooeiOP3w ago
Sure! This is the helper function that my query calls:
export async function getAllMessages(
ctx: QueryCtx,
paginationOpts: PaginationOptsType,
chatId: Id<"chats">,
sortOrder?: SortOrderType,
afterThisCreationTime?: number,
beforeThisCreationTime?: number
) {
sortOrder = sortOrder || "desc"; // For chat history, we want the most recent messages first.

const results: PaginationResult<Doc<"messages">> = await ctx.db
.query("messages")
.withIndex("by_chat_id", (q) =>
q
.eq("chatId", chatId)
.gt("_creationTime", afterThisCreationTime || 0)
.lt("_creationTime", beforeThisCreationTime || Date.now() + 1)
)
.order(sortOrder)
.paginate(paginationOpts);

return {
...results,
page: results.page, // In case we want to modify the records.
};
}
export async function getAllMessages(
ctx: QueryCtx,
paginationOpts: PaginationOptsType,
chatId: Id<"chats">,
sortOrder?: SortOrderType,
afterThisCreationTime?: number,
beforeThisCreationTime?: number
) {
sortOrder = sortOrder || "desc"; // For chat history, we want the most recent messages first.

const results: PaginationResult<Doc<"messages">> = await ctx.db
.query("messages")
.withIndex("by_chat_id", (q) =>
q
.eq("chatId", chatId)
.gt("_creationTime", afterThisCreationTime || 0)
.lt("_creationTime", beforeThisCreationTime || Date.now() + 1)
)
.order(sortOrder)
.paginate(paginationOpts);

return {
...results,
page: results.page, // In case we want to modify the records.
};
}
This is the actual query that calls the above helper:
/**
* Get all messages for the authenticated user, optionally sorted by the given order
*/
export const getAll = query({
args: {
paginationOpts: paginationOptsValidator,
chatId: v.id("chats"),
sortOrder: v.optional(SortOrder),
},
handler: async (ctx, args) => {
const { chatId, sortOrder } = args;
const userId = await authenticationGuard(ctx);
const chat = await getChatById(ctx, chatId);
chatOwnershipCheck(userId, chat.userId);
return await getAllMessages(ctx, args.paginationOpts, chatId, sortOrder);
},
});
/**
* Get all messages for the authenticated user, optionally sorted by the given order
*/
export const getAll = query({
args: {
paginationOpts: paginationOptsValidator,
chatId: v.id("chats"),
sortOrder: v.optional(SortOrder),
},
handler: async (ctx, args) => {
const { chatId, sortOrder } = args;
const userId = await authenticationGuard(ctx);
const chat = await getChatById(ctx, chatId);
chatOwnershipCheck(userId, chat.userId);
return await getAllMessages(ctx, args.paginationOpts, chatId, sortOrder);
},
});
I'm using this custom hook on the frontend:
import { api } from "@api/_generated/api";
import { Id } from "@api/_generated/dataModel";
import { usePaginatedQuery } from "convex/react";

import { MessageType } from "@app/messages/types/message";
import { useEffect, useState } from "react";

const INITIAL_NUM_ITEMS = 20;
const LOAD_MORE_NUM_ITEMS = 10;

export function useQueryMessages(chatId: string) {
const [messages, setMessages] = useState<MessageType[]>([]);
const { results, status, loadMore } = usePaginatedQuery(
// @ts-ignore
api.messages.queries.getAll,
{
chatId: chatId as Id<"chats">,
},
{
initialNumItems: INITIAL_NUM_ITEMS,
},
);

useEffect(() => {
if (results) {
setMessages(results.reverse());
}
}, [results]);

return {
// data: results as MessageType[],
data: messages,
loading: status === "LoadingFirstPage",
error: results === null,
status,
loadMore: () => loadMore(LOAD_MORE_NUM_ITEMS),
};
}
import { api } from "@api/_generated/api";
import { Id } from "@api/_generated/dataModel";
import { usePaginatedQuery } from "convex/react";

import { MessageType } from "@app/messages/types/message";
import { useEffect, useState } from "react";

const INITIAL_NUM_ITEMS = 20;
const LOAD_MORE_NUM_ITEMS = 10;

export function useQueryMessages(chatId: string) {
const [messages, setMessages] = useState<MessageType[]>([]);
const { results, status, loadMore } = usePaginatedQuery(
// @ts-ignore
api.messages.queries.getAll,
{
chatId: chatId as Id<"chats">,
},
{
initialNumItems: INITIAL_NUM_ITEMS,
},
);

useEffect(() => {
if (results) {
setMessages(results.reverse());
}
}, [results]);

return {
// data: results as MessageType[],
data: messages,
loading: status === "LoadingFirstPage",
error: results === null,
status,
loadMore: () => loadMore(LOAD_MORE_NUM_ITEMS),
};
}
lee
lee3w ago
Cool so InvalidCursor indicates that the paginated query's bounds are changing. And the reason is the Date.now(), which is different every time the query runs. To fix, you can conditionally remove that part of the filter
if (beforeThisCreationTime) {
q = q.lt("_creationTime", beforeThisCreationTime);
}
if (beforeThisCreationTime) {
q = q.lt("_creationTime", beforeThisCreationTime);
}
Ali Madooei
Ali MadooeiOP3w ago
Thanks, Lee! This partially fixed my problem. I don't have InvalidCursor anymore but updates to messages (e.g., updating the AI placeholder message as the AI response streams) does not work; looks like the React state does not change. I don't know why but I'm going to try to figure it out. For anyone looking at this thread, I thought I should provide the following information. This assignment doesn't work: q = q.lt("_creationTime", beforeThisCreationTime);. So I ended up doing this:
const results: PaginationResult<Doc<"messages">> = await ctx.db
.query("messages")
.withIndex(
"by_chat_id",
(
q: IndexRangeBuilder<Doc<"messages">, ["chatId", "_creationTime"], 0>,
) => {
let q1, q2, q3;

q1 = q.eq("chatId", chatId);

if (afterThisCreationTime) {
q2 = q1.gt("_creationTime", afterThisCreationTime);
}

if (beforeThisCreationTime) {
q3 = q2 ? q2.lt("_creationTime", beforeThisCreationTime) :
q1.lt("_creationTime", beforeThisCreationTime);
}

return q3 || q2 || q1;
},
)
.order(sortOrder)
.paginate(paginationOpts);
const results: PaginationResult<Doc<"messages">> = await ctx.db
.query("messages")
.withIndex(
"by_chat_id",
(
q: IndexRangeBuilder<Doc<"messages">, ["chatId", "_creationTime"], 0>,
) => {
let q1, q2, q3;

q1 = q.eq("chatId", chatId);

if (afterThisCreationTime) {
q2 = q1.gt("_creationTime", afterThisCreationTime);
}

if (beforeThisCreationTime) {
q3 = q2 ? q2.lt("_creationTime", beforeThisCreationTime) :
q1.lt("_creationTime", beforeThisCreationTime);
}

return q3 || q2 || q1;
},
)
.order(sortOrder)
.paginate(paginationOpts);
I think the IndexRangeBuilder works by updating the type parameter FieldNum so to enforce that we proceed through the fields in index order. I don't know if there is a more elegant way of doing this (excpet going back to chaining). Going back to chaining the queries, I thought I can use Number.MAX_SAFE_INTEGER instead of Date.now() and it seems to work:
const results: PaginationResult<Doc<"messages">> = await ctx.db
.query("messages")
.withIndex(
"by_chat_id",
(
q: IndexRangeBuilder<Doc<"messages">, ["chatId", "_creationTime"], 0>,
) => {

return q
.eq("chatId", chatId)
.gt("_creationTime", afterThisCreationTime ?? 0)
.lt(
"_creationTime",
beforeThisCreationTime ?? Number.MAX_SAFE_INTEGER,
);
},
)
.order(sortOrder)
.paginate(paginationOpts);
const results: PaginationResult<Doc<"messages">> = await ctx.db
.query("messages")
.withIndex(
"by_chat_id",
(
q: IndexRangeBuilder<Doc<"messages">, ["chatId", "_creationTime"], 0>,
) => {

return q
.eq("chatId", chatId)
.gt("_creationTime", afterThisCreationTime ?? 0)
.lt(
"_creationTime",
beforeThisCreationTime ?? Number.MAX_SAFE_INTEGER,
);
},
)
.order(sortOrder)
.paginate(paginationOpts);
@lee if you have any thoughts on why updating any of the messages does not update the React state, please let me know. I'll try to figure it out too, and if I find the problem, will report here for reference for others. Thanks again. PS. by update does not work I mean, let's say I have a list of messages. If I even directly update a message in the database, I don't see the update in the app. The "reactivity" which was creating the realtime feature is lost! And I'm not sure if the problem is the paginated query or something else.
lee
lee3w ago
Good job figuring out the typescript types there I don't see why reactivity isn't working. Things to check: When the data changes, do you see queries rerun in the convex dashboard logs page? Do you see the useEffect(..., [results]) in your custom hook rerunning?
Ali Madooei
Ali MadooeiOP3w ago
Thanks Lee! Yes, the problem was somewhere else in my code and it is resolved now (irrelevant to this discussion). So, I can confirm for anyone reading this thread, the with paginate query was due to using Date.now()

Did you find this page helpful?