From 2e873eba1834600daf7e1597800319aa1ad7e519 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 01:00:25 +0000 Subject: [PATCH] Add pagination tests for TableAggregate with namespace and composite keys This adds tests that validate pagination behavior for the exact scenario reported by a customer: - TableAggregate with namespace function - Composite sortKey: [-doc.totalPoints, doc._id] - pageSize=1 with order='asc' Tests verify: 1. paginate with pageSize=1 returns cursor when more items exist 2. paginate with pageSize=1 iterates through all items correctly 3. paginate with pageSize=10 returns all items at once 4. iter with namespace and composite keys works correctly Also adds B-tree level pagination tests to verify the core implementation. All tests pass on the current codebase, indicating the reported issue may be fixed in the current version or related to customer wrapper code. Co-Authored-By: Ian Macartney --- src/client/index.test.ts | 239 ++++++++++++++++++++++++++++++++++++ src/component/btree.test.ts | 178 +++++++++++++++++++++++++++ 2 files changed, 417 insertions(+) diff --git a/src/client/index.test.ts b/src/client/index.test.ts index 1a21171..dc509be 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -882,3 +882,242 @@ describe("TableAggregate with namespace", () => { }); }); }); + +describe("TableAggregate pagination", () => { + const leaderboardSchema = defineSchema({ + leaderboard: defineTable({ + monthKey: v.string(), + totalPoints: v.number(), + }), + }); + + type LeaderboardDataModel = + DataModelFromSchemaDefinition; + + function setupLeaderboardTest() { + const t = convexTest(leaderboardSchema, modules); + t.registerComponent("aggregate", componentSchema, componentModules); + return t; + } + + test("paginate with pageSize=1 returns cursor when more items exist (customer scenario)", async () => { + const t = setupLeaderboardTest(); + + const leaderboardAggregate = new TableAggregate<{ + Namespace: string; + Key: [number, string]; + DataModel: LeaderboardDataModel; + TableName: "leaderboard"; + }>(components.aggregate, { + namespace: (doc) => doc.monthKey, + sortKey: (doc) => [-doc.totalPoints, doc._id], + }); + + await t.run(async (ctx) => { + // Insert 3 items in the namespace (simulating the customer's scenario) + const id1 = await ctx.db.insert("leaderboard", { + monthKey: "2025-11", + totalPoints: 376, + }); + const doc1 = await ctx.db.get(id1); + await leaderboardAggregate.insert(ctx, doc1!); + + const id2 = await ctx.db.insert("leaderboard", { + monthKey: "2025-11", + totalPoints: 60, + }); + const doc2 = await ctx.db.get(id2); + await leaderboardAggregate.insert(ctx, doc2!); + + const id3 = await ctx.db.insert("leaderboard", { + monthKey: "2025-11", + totalPoints: 6, + }); + const doc3 = await ctx.db.get(id3); + await leaderboardAggregate.insert(ctx, doc3!); + + // Verify count is 3 + const count = await leaderboardAggregate.count(ctx, { + namespace: "2025-11", + }); + expect(count).toBe(3); + + // Paginate with pageSize=1, order=asc + const result = await leaderboardAggregate.paginate(ctx, { + namespace: "2025-11", + pageSize: 1, + order: "asc", + }); + + expect(result.page.length).toBe(1); + expect(result.isDone).toBe(false); + expect(result.cursor).not.toBe(""); + }); + }); + + test("paginate with pageSize=1 iterates through all items correctly (customer scenario)", async () => { + const t = setupLeaderboardTest(); + + const leaderboardAggregate = new TableAggregate<{ + Namespace: string; + Key: [number, string]; + DataModel: LeaderboardDataModel; + TableName: "leaderboard"; + }>(components.aggregate, { + namespace: (doc) => doc.monthKey, + sortKey: (doc) => [-doc.totalPoints, doc._id], + }); + + await t.run(async (ctx) => { + // Insert 3 items + const id1 = await ctx.db.insert("leaderboard", { + monthKey: "2025-11", + totalPoints: 376, + }); + const doc1 = await ctx.db.get(id1); + await leaderboardAggregate.insert(ctx, doc1!); + + const id2 = await ctx.db.insert("leaderboard", { + monthKey: "2025-11", + totalPoints: 60, + }); + const doc2 = await ctx.db.get(id2); + await leaderboardAggregate.insert(ctx, doc2!); + + const id3 = await ctx.db.insert("leaderboard", { + monthKey: "2025-11", + totalPoints: 6, + }); + const doc3 = await ctx.db.get(id3); + await leaderboardAggregate.insert(ctx, doc3!); + + // Paginate through all items with pageSize=1 + const allItems: Array<{ key: [number, string]; id: string }> = []; + let cursor: string | undefined = undefined; + let iterations = 0; + const maxIterations = 10; // Safety limit + + while (iterations < maxIterations) { + const result = await leaderboardAggregate.paginate(ctx, { + namespace: "2025-11", + pageSize: 1, + order: "asc", + cursor, + }); + allItems.push(...result.page); + if (result.isDone) { + break; + } + cursor = result.cursor; + iterations++; + } + + expect(allItems.length).toBe(3); + // Keys should be sorted in ascending order: [-376, ...], [-60, ...], [-6, ...] + expect(allItems[0].key[0]).toBe(-376); + expect(allItems[1].key[0]).toBe(-60); + expect(allItems[2].key[0]).toBe(-6); + }); + }); + + test("paginate with pageSize=10 returns all items at once", async () => { + const t = setupLeaderboardTest(); + + const leaderboardAggregate = new TableAggregate<{ + Namespace: string; + Key: [number, string]; + DataModel: LeaderboardDataModel; + TableName: "leaderboard"; + }>(components.aggregate, { + namespace: (doc) => doc.monthKey, + sortKey: (doc) => [-doc.totalPoints, doc._id], + }); + + await t.run(async (ctx) => { + // Insert 3 items + const id1 = await ctx.db.insert("leaderboard", { + monthKey: "2025-11", + totalPoints: 376, + }); + const doc1 = await ctx.db.get(id1); + await leaderboardAggregate.insert(ctx, doc1!); + + const id2 = await ctx.db.insert("leaderboard", { + monthKey: "2025-11", + totalPoints: 60, + }); + const doc2 = await ctx.db.get(id2); + await leaderboardAggregate.insert(ctx, doc2!); + + const id3 = await ctx.db.insert("leaderboard", { + monthKey: "2025-11", + totalPoints: 6, + }); + const doc3 = await ctx.db.get(id3); + await leaderboardAggregate.insert(ctx, doc3!); + + // Paginate with pageSize=10 should return all 3 items + const result = await leaderboardAggregate.paginate(ctx, { + namespace: "2025-11", + pageSize: 10, + order: "asc", + }); + + expect(result.page.length).toBe(3); + expect(result.isDone).toBe(true); + }); + }); + + test("iter with namespace and composite keys works correctly", async () => { + const t = setupLeaderboardTest(); + + const leaderboardAggregate = new TableAggregate<{ + Namespace: string; + Key: [number, string]; + DataModel: LeaderboardDataModel; + TableName: "leaderboard"; + }>(components.aggregate, { + namespace: (doc) => doc.monthKey, + sortKey: (doc) => [-doc.totalPoints, doc._id], + }); + + await t.run(async (ctx) => { + // Insert 3 items + const id1 = await ctx.db.insert("leaderboard", { + monthKey: "2025-11", + totalPoints: 376, + }); + const doc1 = await ctx.db.get(id1); + await leaderboardAggregate.insert(ctx, doc1!); + + const id2 = await ctx.db.insert("leaderboard", { + monthKey: "2025-11", + totalPoints: 60, + }); + const doc2 = await ctx.db.get(id2); + await leaderboardAggregate.insert(ctx, doc2!); + + const id3 = await ctx.db.insert("leaderboard", { + monthKey: "2025-11", + totalPoints: 6, + }); + const doc3 = await ctx.db.get(id3); + await leaderboardAggregate.insert(ctx, doc3!); + + // Use iter to get all items + const allItems: Array<{ key: [number, string]; id: string }> = []; + for await (const item of leaderboardAggregate.iter(ctx, { + namespace: "2025-11", + order: "asc", + pageSize: 1, // Use small pageSize to test pagination + })) { + allItems.push(item); + } + + expect(allItems.length).toBe(3); + expect(allItems[0].key[0]).toBe(-376); + expect(allItems[1].key[0]).toBe(-60); + expect(allItems[2].key[0]).toBe(-6); + }); + }); +}); diff --git a/src/component/btree.test.ts b/src/component/btree.test.ts index d009d70..63e0b90 100644 --- a/src/component/btree.test.ts +++ b/src/component/btree.test.ts @@ -383,6 +383,184 @@ describe("namespaced btree", () => { }); }); +describe("pagination", () => { + test("paginate with pageSize=1 returns cursor when more items exist (leaf node)", async () => { + const t = convexTest(schema, modules); + await t.run(async (ctx) => { + // Use default maxNodeSize (16) so 3 items fit in a single leaf node + await getOrCreateTree(ctx.db, undefined, 16, false); + + // Insert 3 items + await insertHandler(ctx, { key: 1, value: "a" }); + await insertHandler(ctx, { key: 2, value: "b" }); + await insertHandler(ctx, { key: 3, value: "c" }); + + // Verify count is 3 + const { count } = await aggregateBetweenHandler(ctx, {}); + expect(count).toBe(3); + + // Paginate with pageSize=1, order=asc + const result = await paginateHandler(ctx, { + limit: 1, + order: "asc", + }); + + expect(result.page.length).toBe(1); + expect(result.page[0].k).toBe(1); + expect(result.isDone).toBe(false); + expect(result.cursor).not.toBe(""); + }); + }); + + test("paginate with pageSize=1 returns cursor when more items exist (multi-level tree)", async () => { + const t = convexTest(schema, modules); + await t.run(async (ctx) => { + // Use small maxNodeSize to force multi-level tree + await getOrCreateTree(ctx.db, undefined, 4, false); + + // Insert 3 items + await insertHandler(ctx, { key: 1, value: "a" }); + await insertHandler(ctx, { key: 2, value: "b" }); + await insertHandler(ctx, { key: 3, value: "c" }); + + // Verify count is 3 + const { count } = await aggregateBetweenHandler(ctx, {}); + expect(count).toBe(3); + + // Paginate with pageSize=1, order=asc + const result = await paginateHandler(ctx, { + limit: 1, + order: "asc", + }); + + expect(result.page.length).toBe(1); + expect(result.page[0].k).toBe(1); + expect(result.isDone).toBe(false); + expect(result.cursor).not.toBe(""); + }); + }); + + test("paginate with pageSize=1 iterates through all items correctly", async () => { + const t = convexTest(schema, modules); + await t.run(async (ctx) => { + await getOrCreateTree(ctx.db, undefined, 16, false); + + // Insert 3 items + await insertHandler(ctx, { key: 1, value: "a" }); + await insertHandler(ctx, { key: 2, value: "b" }); + await insertHandler(ctx, { key: 3, value: "c" }); + + // Paginate through all items with pageSize=1 + const allItems: Item[] = []; + let cursor: string | undefined = undefined; + let iterations = 0; + const maxIterations = 10; // Safety limit + + while (iterations < maxIterations) { + const result = await paginateHandler(ctx, { + limit: 1, + order: "asc", + cursor, + }); + allItems.push(...result.page); + if (result.isDone) { + break; + } + cursor = result.cursor; + iterations++; + } + + expect(allItems.length).toBe(3); + expect(allItems.map((i) => i.k)).toEqual([1, 2, 3]); + }); + }); + + test("paginate with namespace and pageSize=1 returns cursor when more items exist", async () => { + const t = convexTest(schema, modules); + await t.run(async (ctx) => { + await getOrCreateTree(ctx.db, "2025-11", 16, false); + + // Insert 3 items in the namespace (simulating the customer's scenario) + await insertHandler(ctx, { + key: [-376, "w17cfq6k"], + value: "w17cfq6k", + namespace: "2025-11", + }); + await insertHandler(ctx, { + key: [-60, "w17bxthk"], + value: "w17bxthk", + namespace: "2025-11", + }); + await insertHandler(ctx, { + key: [-6, "w17brm4f"], + value: "w17brm4f", + namespace: "2025-11", + }); + + // Verify count is 3 + const { count } = await aggregateBetweenHandler(ctx, { + namespace: "2025-11", + }); + expect(count).toBe(3); + + // Paginate with pageSize=1, order=asc + const result = await paginateHandler(ctx, { + limit: 1, + order: "asc", + namespace: "2025-11", + }); + + expect(result.page.length).toBe(1); + expect(result.isDone).toBe(false); + expect(result.cursor).not.toBe(""); + }); + }); + + test("paginate with composite array keys and pageSize=1", async () => { + const t = convexTest(schema, modules); + await t.run(async (ctx) => { + await getOrCreateTree(ctx.db, undefined, 16, false); + + // Insert items with composite array keys (like the customer's sortKey: [-totalPoints, _id]) + await insertHandler(ctx, { key: [-100, "id1"], value: "id1" }); + await insertHandler(ctx, { key: [-50, "id2"], value: "id2" }); + await insertHandler(ctx, { key: [-25, "id3"], value: "id3" }); + + // Verify count is 3 + const { count } = await aggregateBetweenHandler(ctx, {}); + expect(count).toBe(3); + + // Paginate with pageSize=1, order=asc + const result = await paginateHandler(ctx, { + limit: 1, + order: "asc", + }); + + expect(result.page.length).toBe(1); + expect(result.isDone).toBe(false); + expect(result.cursor).not.toBe(""); + + // Continue pagination to get all items + const allItems: Item[] = [...result.page]; + let cursor = result.cursor; + while (cursor !== "") { + const nextResult = await paginateHandler(ctx, { + limit: 1, + order: "asc", + cursor, + }); + allItems.push(...nextResult.page); + if (nextResult.isDone) { + break; + } + cursor = nextResult.cursor; + } + + expect(allItems.length).toBe(3); + }); + }); +}); + class SimpleBTree { private items: Item[] = []; constructor() {}