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() {}